use std::collections::HashSet; use std::sync::{Arc, Mutex}; /// Manages per-site build scheduling: immediate execution and a depth-1 queue. /// /// When a build is already in progress, a single rebuild can be queued. /// Subsequent requests while a rebuild is already queued are collapsed (no-op). pub struct BuildScheduler { pub in_progress: Mutex>, pub queued: Mutex>, } impl BuildScheduler { #[must_use] pub fn new() -> Self { Self { in_progress: Mutex::new(HashSet::new()), queued: Mutex::new(HashSet::new()), } } /// Queue a rebuild for a site that is currently building. /// Returns `true` if newly queued, `false` if already queued (collapse). pub(crate) fn try_queue(&self, site_name: &str) -> bool { #[allow(clippy::unwrap_used)] self.queued.lock().unwrap().insert(site_name.to_owned()) } /// Check and clear queued rebuild. Returns `true` if there was one. pub(crate) fn take_queued(&self, site_name: &str) -> bool { #[allow(clippy::unwrap_used)] self.queued.lock().unwrap().remove(site_name) } } impl Default for BuildScheduler { fn default() -> Self { Self::new() } } /// RAII guard for per-site build exclusion. /// Inserting into the scheduler's `in_progress` set acquires the lock; /// dropping removes it. pub(crate) struct BuildGuard { site_name: String, scheduler: Arc, } impl BuildGuard { pub(crate) fn try_acquire(site_name: String, scheduler: &Arc) -> Option { #[allow(clippy::unwrap_used)] let inserted = scheduler .in_progress .lock() .unwrap() .insert(site_name.clone()); if inserted { Some(Self { site_name, scheduler: Arc::clone(scheduler), }) } else { None } } } impl Drop for BuildGuard { fn drop(&mut self) { #[allow(clippy::unwrap_used)] self.scheduler .in_progress .lock() .unwrap() .remove(&self.site_name); } } #[cfg(test)] #[allow(clippy::unwrap_used, clippy::indexing_slicing)] mod tests { use super::*; #[test] fn build_guard_try_acquire_success() { let scheduler = Arc::new(BuildScheduler::new()); let guard = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); assert!(guard.is_some()); assert!(scheduler.in_progress.lock().unwrap().contains("my-site")); } #[test] fn build_guard_try_acquire_fails_when_held() { let scheduler = Arc::new(BuildScheduler::new()); let _guard = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); let second = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); assert!(second.is_none()); } #[test] fn build_guard_drop_releases_lock() { let scheduler = Arc::new(BuildScheduler::new()); { let _guard = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); assert!(scheduler.in_progress.lock().unwrap().contains("my-site")); } // Guard dropped — lock released assert!(!scheduler.in_progress.lock().unwrap().contains("my-site")); let again = BuildGuard::try_acquire("my-site".to_owned(), &scheduler); assert!(again.is_some()); } #[test] fn scheduler_try_queue_succeeds() { let scheduler = BuildScheduler::new(); assert!(scheduler.try_queue("my-site")); assert!(scheduler.queued.lock().unwrap().contains("my-site")); } #[test] fn scheduler_try_queue_collapse() { let scheduler = BuildScheduler::new(); assert!(scheduler.try_queue("my-site")); assert!(!scheduler.try_queue("my-site")); } #[test] fn scheduler_take_queued_clears_flag() { let scheduler = BuildScheduler::new(); scheduler.try_queue("my-site"); assert!(scheduler.take_queued("my-site")); assert!(!scheduler.queued.lock().unwrap().contains("my-site")); } #[test] fn scheduler_take_queued_returns_false_when_empty() { let scheduler = BuildScheduler::new(); assert!(!scheduler.take_queued("my-site")); } }