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    let request = ColdBuildAdmissionRequest::new(kind, ColdBuildAdmissionClass::Maintenance);
56    acquire_blocking_while_inner(limiter, kind, Some(&request), admitted, || false)
57}
58
59/// Identify the source of a cold-build request without exposing a limiter knob.
60///
61/// The classes deliberately have no absolute priority ordering. When both
62/// classes are waiting, the limiter avoids admitting the class that was admitted
63/// most recently. This lets an interactive inspect take the next release after
64/// maintenance without starving maintenance under sustained interactive load.
65#[derive(Clone, Copy, Debug, Eq, PartialEq)]
66pub(crate) enum ColdBuildAdmissionClass {
67    InspectTriggered,
68    Maintenance,
69}
70
71impl ColdBuildAdmissionClass {
72    const fn index(self) -> usize {
73        match self {
74            Self::InspectTriggered => 0,
75            Self::Maintenance => 1,
76        }
77    }
78
79    const fn other_index(self) -> usize {
80        match self {
81            Self::InspectTriggered => Self::Maintenance.index(),
82            Self::Maintenance => Self::InspectTriggered.index(),
83        }
84    }
85
86    const fn label(self) -> &'static str {
87        match self {
88            Self::InspectTriggered => "inspect-triggered",
89            Self::Maintenance => "maintenance",
90        }
91    }
92}
93
94/// Internal request metadata attached to an admission attempt.
95#[derive(Clone, Debug, Eq, PartialEq)]
96pub(crate) struct ColdBuildAdmissionRequest {
97    request_id: String,
98    class: ColdBuildAdmissionClass,
99}
100
101impl ColdBuildAdmissionRequest {
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/// Attempt immediate admission without bypassing queued requests from the other
123/// class. Background schedulers use this when they can defer rejected work.
124pub(crate) fn try_acquire_classified_with_limiter(
125    limiter: &Arc<ColdBuildLimiter>,
126    request: &ColdBuildAdmissionRequest,
127) -> Option<ColdBuildPermit> {
128    limiter.try_acquire_classified(request, || true)
129}
130
131/// Acquire a limiter permit for a classified request while it remains admitted
132/// and uncancelled.
133///
134/// Cancellation is sampled before every acquisition attempt and again after a
135/// permit has been acquired. The second check closes the gap before a build can
136/// start: a newly cancelled request returns the permit without emitting an
137/// admission event.
138pub(crate) fn acquire_blocking_while_cancellable_with_limiter(
139    limiter: &Arc<ColdBuildLimiter>,
140    kind: &str,
141    request: ColdBuildAdmissionRequest,
142    admitted: impl Fn() -> bool,
143    cancelled: impl Fn() -> bool,
144) -> Option<ColdBuildPermit> {
145    acquire_blocking_while_inner(limiter, kind, Some(&request), admitted, cancelled)
146}
147
148fn acquire_blocking_while_inner(
149    limiter: &Arc<ColdBuildLimiter>,
150    kind: &str,
151    request: Option<&ColdBuildAdmissionRequest>,
152    admitted: impl Fn() -> bool,
153    cancelled: impl Fn() -> bool,
154) -> Option<ColdBuildPermit> {
155    let _waiter = request.map(|request| AdmissionWaiter::register(limiter, request.class));
156    let started = Instant::now();
157    let mut logged = false;
158    loop {
159        if !admitted() || cancelled() {
160            return None;
161        }
162        let revoked_after_acquire = std::cell::Cell::new(false);
163        let permit = match request {
164            Some(request) => limiter.try_acquire_classified(request, || {
165                let still_admitted = admitted() && !cancelled();
166                revoked_after_acquire.set(!still_admitted);
167                still_admitted
168            }),
169            None => limiter.try_acquire().and_then(|permit| {
170                // A request can become unbound or cancelled after the pre-attempt
171                // check but before the permit is acquired. Recheck while owning
172                // the slot; dropping the permit returns it before any build starts.
173                if admitted() && !cancelled() {
174                    Some(permit)
175                } else {
176                    revoked_after_acquire.set(true);
177                    drop(permit);
178                    None
179                }
180            }),
181        };
182        if revoked_after_acquire.get() {
183            return None;
184        }
185        if let Some(permit) = permit {
186            if logged {
187                match request {
188                    Some(request) => crate::slog_info!(
189                        "{} cold-build slot acquired after {}ms wait: request={} kind={}",
190                        request.class.label(),
191                        started.elapsed().as_millis(),
192                        request.request_id,
193                        kind
194                    ),
195                    None => crate::slog_info!(
196                        "maintenance build slot acquired after {}ms wait: {}",
197                        started.elapsed().as_millis(),
198                        kind
199                    ),
200                }
201            }
202            return Some(permit);
203        }
204        if !logged {
205            match request {
206                Some(request) => crate::slog_info!(
207                    "{} cold-build request queued behind concurrency cap ({}): request={} kind={}",
208                    request.class.label(),
209                    limiter.limit(),
210                    request.request_id,
211                    kind
212                ),
213                None => crate::slog_info!(
214                    "maintenance build queued behind concurrency cap ({}): {}",
215                    limiter.limit(),
216                    kind
217                ),
218            }
219            logged = true;
220        }
221        std::thread::sleep(Duration::from_millis(100));
222    }
223}
224
225pub fn limit() -> usize {
226    GLOBAL_COLD_BUILD_LIMITER.limit()
227}
228
229#[cfg(test)]
230pub(crate) fn test_limiter(limit: usize) -> Arc<ColdBuildLimiter> {
231    Arc::new(ColdBuildLimiter::new(limit))
232}
233
234#[cfg(test)]
235pub(crate) fn acquire_blocking_while_with_test_limiter(
236    limiter: &Arc<ColdBuildLimiter>,
237    kind: &str,
238    admitted: impl Fn() -> bool,
239) -> Option<ColdBuildPermit> {
240    acquire_blocking_while_with_limiter(limiter, kind, admitted)
241}
242
243#[derive(Debug)]
244pub(crate) struct ColdBuildLimiter {
245    available: AtomicUsize,
246    limit: usize,
247    admission_state: Mutex<AdmissionState>,
248}
249
250#[derive(Debug)]
251struct AdmissionState {
252    waiting_by_class: [usize; 2],
253    last_admitted_class: Option<ColdBuildAdmissionClass>,
254    next_admission_order: u64,
255    events: VecDeque<ColdBuildAdmissionEvent>,
256}
257
258impl ColdBuildLimiter {
259    fn new(limit: usize) -> Self {
260        let limit = limit.max(1);
261        Self {
262            available: AtomicUsize::new(limit),
263            limit,
264            admission_state: Mutex::new(AdmissionState {
265                waiting_by_class: [0; 2],
266                last_admitted_class: None,
267                next_admission_order: 1,
268                events: VecDeque::with_capacity(ADMISSION_EVENT_RETENTION),
269            }),
270        }
271    }
272
273    pub(crate) fn limit(&self) -> usize {
274        self.limit
275    }
276
277    pub(crate) fn try_acquire(self: &Arc<Self>) -> Option<ColdBuildPermit> {
278        loop {
279            let available = self.available.load(Ordering::Acquire);
280            if available == 0 {
281                return None;
282            }
283            if self
284                .available
285                .compare_exchange(
286                    available,
287                    available - 1,
288                    Ordering::AcqRel,
289                    Ordering::Acquire,
290                )
291                .is_ok()
292            {
293                return Some(ColdBuildPermit {
294                    limiter: Arc::clone(self),
295                });
296            }
297        }
298    }
299
300    fn try_acquire_classified(
301        self: &Arc<Self>,
302        request: &ColdBuildAdmissionRequest,
303        admitted_after_acquire: impl FnOnce() -> bool,
304    ) -> Option<ColdBuildPermit> {
305        let mut state = self
306            .admission_state
307            .lock()
308            .unwrap_or_else(std::sync::PoisonError::into_inner);
309        // Class alternation arbitrates the final released slot. When several
310        // slots are free, admitting both classes is not starvation and avoids
311        // stranding independent roots behind an artificial one-at-a-time turn.
312        let available = self.available.load(Ordering::Acquire);
313        if available <= 1
314            && state.waiting_by_class[request.class.other_index()] > 0
315            && state.last_admitted_class == Some(request.class)
316        {
317            return None;
318        }
319        let permit = self.try_acquire()?;
320        if !admitted_after_acquire() {
321            drop(permit);
322            return None;
323        }
324        Self::record_admission_locked(&mut state, request);
325        Some(permit)
326    }
327
328    fn record_admission_locked(state: &mut AdmissionState, request: &ColdBuildAdmissionRequest) {
329        let event = ColdBuildAdmissionEvent {
330            request_id: request.request_id.clone(),
331            class: request.class,
332            admission_order: state.next_admission_order,
333        };
334        state.next_admission_order += 1;
335        state.last_admitted_class = Some(request.class);
336        if state.events.len() == ADMISSION_EVENT_RETENTION {
337            state.events.pop_front();
338        }
339        state.events.push_back(event);
340    }
341
342    /// Expose recorded admissions to internal tests and harness code so they
343    /// can verify limiter behavior without parsing log output.
344    #[cfg_attr(not(test), allow(dead_code))]
345    pub(crate) fn admission_events(&self) -> Vec<ColdBuildAdmissionEvent> {
346        self.admission_state
347            .lock()
348            .unwrap_or_else(std::sync::PoisonError::into_inner)
349            .events
350            .iter()
351            .cloned()
352            .collect()
353    }
354
355    #[cfg(test)]
356    fn waiting_by_class_for_test(&self) -> [usize; 2] {
357        self.admission_state
358            .lock()
359            .unwrap_or_else(std::sync::PoisonError::into_inner)
360            .waiting_by_class
361    }
362}
363
364struct AdmissionWaiter {
365    limiter: Arc<ColdBuildLimiter>,
366    class: ColdBuildAdmissionClass,
367}
368
369impl AdmissionWaiter {
370    fn register(limiter: &Arc<ColdBuildLimiter>, class: ColdBuildAdmissionClass) -> Self {
371        let mut state = limiter
372            .admission_state
373            .lock()
374            .unwrap_or_else(std::sync::PoisonError::into_inner);
375        state.waiting_by_class[class.index()] += 1;
376        drop(state);
377        Self {
378            limiter: Arc::clone(limiter),
379            class,
380        }
381    }
382}
383
384impl Drop for AdmissionWaiter {
385    fn drop(&mut self) {
386        let mut state = self
387            .limiter
388            .admission_state
389            .lock()
390            .unwrap_or_else(std::sync::PoisonError::into_inner);
391        let waiting = &mut state.waiting_by_class[self.class.index()];
392        debug_assert!(*waiting > 0);
393        *waiting = waiting.saturating_sub(1);
394    }
395}
396
397#[derive(Debug)]
398pub struct ColdBuildPermit {
399    limiter: Arc<ColdBuildLimiter>,
400}
401
402impl Drop for ColdBuildPermit {
403    fn drop(&mut self) {
404        let previous = self.limiter.available.fetch_add(1, Ordering::Release);
405        debug_assert!(previous < self.limiter.limit);
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    // These tests mutate the process-global limiter; run them one at a time.
414    fn serial() -> std::sync::MutexGuard<'static, ()> {
415        static M: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
416        M.get_or_init(|| std::sync::Mutex::new(()))
417            .lock()
418            .unwrap_or_else(std::sync::PoisonError::into_inner)
419    }
420
421    fn wait_for_waiters(limiter: &ColdBuildLimiter) {
422        let deadline = Instant::now() + Duration::from_secs(3);
423        loop {
424            let waiting = limiter.waiting_by_class_for_test();
425            if waiting[ColdBuildAdmissionClass::InspectTriggered.index()] > 0
426                && waiting[ColdBuildAdmissionClass::Maintenance.index()] > 0
427            {
428                return;
429            }
430            assert!(
431                Instant::now() < deadline,
432                "both admission classes must remain queued; waiting={waiting:?}"
433            );
434            std::thread::yield_now();
435        }
436    }
437
438    #[test]
439    fn permits_release_on_drop() {
440        let _serial = serial();
441        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
442        {
443            let _a = acquire_blocking("test-a");
444            let _b = acquire_blocking("test-b");
445            assert_eq!(
446                GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
447                before - 2
448            );
449        }
450        assert_eq!(
451            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
452            before
453        );
454    }
455
456    #[test]
457    fn acquire_blocking_waits_until_release() {
458        let _serial = serial();
459        // Drain every slot, then prove a waiter blocks until one holder drops.
460        let mut held: Vec<ColdBuildPermit> = Vec::new();
461        while let Some(permit) = try_acquire() {
462            held.push(permit);
463        }
464        let waiter = std::thread::spawn(|| {
465            let _p = acquire_blocking("waiter");
466        });
467        std::thread::sleep(std::time::Duration::from_millis(250));
468        assert!(!waiter.is_finished(), "waiter must block while cap is full");
469        drop(held.pop());
470        waiter.join().expect("waiter finishes after release");
471        drop(held);
472    }
473
474    #[test]
475    fn admission_revoked_between_check_and_permit_drops_the_slot() {
476        let _serial = serial();
477        let before = GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire);
478        let checks = AtomicUsize::new(0);
479
480        let permit = acquire_blocking_while("revoked-after-cas", || {
481            checks.fetch_add(1, Ordering::SeqCst) == 0
482        });
483
484        assert!(permit.is_none());
485        assert_eq!(checks.load(Ordering::SeqCst), 2);
486        assert_eq!(
487            GLOBAL_COLD_BUILD_LIMITER.available.load(Ordering::Acquire),
488            before,
489            "revoked admission must return the just-acquired slot"
490        );
491    }
492
493    #[test]
494    fn conditional_waiter_cancels_without_consuming_a_released_slot() {
495        let _serial = serial();
496        let mut held = Vec::new();
497        while let Some(permit) = try_acquire() {
498            held.push(permit);
499        }
500        let admitted = Arc::new(std::sync::atomic::AtomicBool::new(true));
501        let waiter_admitted = Arc::clone(&admitted);
502        let waiter = std::thread::spawn(move || {
503            acquire_blocking_while("conditional waiter", || {
504                waiter_admitted.load(Ordering::SeqCst)
505            })
506        });
507        std::thread::sleep(Duration::from_millis(150));
508        admitted.store(false, Ordering::SeqCst);
509        assert!(
510            waiter.join().expect("conditional waiter joins").is_none(),
511            "revoked work must leave the cold-build queue without taking a permit"
512        );
513        drop(held);
514    }
515
516    #[test]
517    fn cancellation_after_acquisition_returns_the_permit_without_an_event() {
518        let limiter = test_limiter(1);
519        let cancellation_checks = AtomicUsize::new(0);
520
521        let permit = acquire_blocking_while_cancellable_with_limiter(
522            &limiter,
523            "cancel-after-acquire",
524            ColdBuildAdmissionRequest::new(
525                "inspect-cancelled",
526                ColdBuildAdmissionClass::InspectTriggered,
527            ),
528            || true,
529            || cancellation_checks.fetch_add(1, Ordering::SeqCst) > 0,
530        );
531
532        assert!(permit.is_none());
533        assert_eq!(cancellation_checks.load(Ordering::SeqCst), 2);
534        assert_eq!(
535            limiter.available.load(Ordering::Acquire),
536            1,
537            "post-acquisition cancellation must return the permit"
538        );
539        assert!(
540            limiter.admission_events().is_empty(),
541            "cancelled work must not emit a successful admission"
542        );
543    }
544
545    #[test]
546    fn inspect_waiter_takes_next_release_ahead_of_queued_maintenance() {
547        let limiter = test_limiter(1);
548        let active_request = ColdBuildAdmissionRequest::new(
549            "active-semantic-seed",
550            ColdBuildAdmissionClass::Maintenance,
551        );
552        let active = try_acquire_classified_with_limiter(&limiter, &active_request)
553            .expect("active maintenance build holds the slot");
554        let (admitted_tx, admitted_rx) = std::sync::mpsc::channel();
555        let mut waiters = Vec::new();
556
557        for (request_id, class) in [
558            ("queued-refresh", ColdBuildAdmissionClass::Maintenance),
559            (
560                "blocking-inspect",
561                ColdBuildAdmissionClass::InspectTriggered,
562            ),
563        ] {
564            let limiter = Arc::clone(&limiter);
565            let admitted_tx = admitted_tx.clone();
566            waiters.push(std::thread::spawn(move || {
567                let permit = acquire_blocking_while_cancellable_with_limiter(
568                    &limiter,
569                    request_id,
570                    ColdBuildAdmissionRequest::new(request_id, class),
571                    || true,
572                    || false,
573                )
574                .expect("queued build is admitted");
575                admitted_tx
576                    .send((class, permit))
577                    .expect("test receives admitted permit");
578            }));
579        }
580        drop(admitted_tx);
581        wait_for_waiters(&limiter);
582        drop(active);
583
584        let (first_class, first_permit) = admitted_rx
585            .recv_timeout(Duration::from_secs(3))
586            .expect("released slot admits interactive inspect");
587        assert_eq!(first_class, ColdBuildAdmissionClass::InspectTriggered);
588        assert!(
589            admitted_rx.try_recv().is_err(),
590            "maintenance remains deferred"
591        );
592        drop(first_permit);
593
594        let (second_class, second_permit) = admitted_rx
595            .recv_timeout(Duration::from_secs(3))
596            .expect("maintenance resumes after inspect releases its slot");
597        assert_eq!(second_class, ColdBuildAdmissionClass::Maintenance);
598        drop(second_permit);
599        for waiter in waiters {
600            waiter.join().expect("admission waiter joins");
601        }
602    }
603
604    #[test]
605    fn admission_events_cover_both_classes_across_the_fixed_32_release_schedule() {
606        const RELEASE_COUNT: usize = 32;
607
608        let limiter = test_limiter(1);
609        let cancelled = Arc::new(std::sync::atomic::AtomicBool::new(false));
610        let (permit_tx, permit_rx) = std::sync::mpsc::channel();
611        let initial_permit = limiter.try_acquire().expect("hold the only slot");
612        let mut waiters = Vec::new();
613
614        for (request_id, class) in [
615            ("inspect-request", ColdBuildAdmissionClass::InspectTriggered),
616            ("maintenance-request", ColdBuildAdmissionClass::Maintenance),
617        ] {
618            let limiter = Arc::clone(&limiter);
619            let cancelled = Arc::clone(&cancelled);
620            let permit_tx = permit_tx.clone();
621            waiters.push(std::thread::spawn(move || {
622                while !cancelled.load(Ordering::SeqCst) {
623                    let permit = acquire_blocking_while_cancellable_with_limiter(
624                        &limiter,
625                        "fixed-release-test",
626                        ColdBuildAdmissionRequest::new(request_id, class),
627                        || true,
628                        || cancelled.load(Ordering::SeqCst),
629                    );
630                    let Some(permit) = permit else {
631                        return;
632                    };
633                    if permit_tx.send(permit).is_err() {
634                        return;
635                    }
636                }
637            }));
638        }
639        drop(permit_tx);
640
641        wait_for_waiters(&limiter);
642        let mut released_permit = Some(initial_permit);
643        for release in 1..RELEASE_COUNT {
644            wait_for_waiters(&limiter);
645            drop(released_permit.take());
646            released_permit = Some(
647                permit_rx
648                    .recv_timeout(Duration::from_secs(3))
649                    .unwrap_or_else(|error| {
650                        panic!("release {release} must admit a waiter: {error}")
651                    }),
652            );
653        }
654        wait_for_waiters(&limiter);
655        drop(released_permit);
656        let consumed_by_build = permit_rx
657            .recv_timeout(Duration::from_secs(3))
658            .unwrap_or_else(|error| panic!("release {RELEASE_COUNT} must admit a waiter: {error}"));
659
660        cancelled.store(true, Ordering::SeqCst);
661        for waiter in waiters {
662            waiter.join().expect("cancelled waiter joins");
663        }
664
665        let events = limiter.admission_events();
666        assert_eq!(events.len(), RELEASE_COUNT);
667        assert!(events
668            .iter()
669            .any(|event| event.class == ColdBuildAdmissionClass::InspectTriggered));
670        assert!(events
671            .iter()
672            .any(|event| event.class == ColdBuildAdmissionClass::Maintenance));
673        assert!(events.iter().all(|event| matches!(
674            event.request_id.as_str(),
675            "inspect-request" | "maintenance-request"
676        )));
677        assert!(events
678            .iter()
679            .enumerate()
680            .all(|(index, event)| event.admission_order == index as u64 + 1));
681
682        assert_eq!(
683            limiter.available.load(Ordering::Acquire),
684            0,
685            "the final acquired permit must remain accounted for by the consumed build"
686        );
687        drop(consumed_by_build);
688        assert_eq!(
689            limiter.available.load(Ordering::Acquire),
690            1,
691            "releasing the consumed build permit must restore the limiter slot"
692        );
693    }
694}