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
11const 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
31pub fn acquire_blocking(kind: &str) -> ColdBuildPermit {
40 acquire_blocking_while(kind, || true).expect("unconditional cold-build admission")
41}
42
43pub 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#[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#[derive(Clone, Debug, Eq, PartialEq)]
94pub(crate) struct ColdBuildAdmissionRequest {
95 request_id: String,
96 class: ColdBuildAdmissionClass,
97}
98
99impl ColdBuildAdmissionRequest {
100 #[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#[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#[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 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 #[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 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 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}