use std::sync::Arc;
use std::time::Duration;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::scrape_class::ScrapeClass;
fn record_lane_wait(lane: &str, class: ScrapeClass, waited: Duration) {
let class = match class {
ScrapeClass::Interactive => "interactive",
ScrapeClass::Batch => "batch",
};
crate::metrics::metrics()
.reserved_lane_wait_seconds
.with_label_values(&[lane, class])
.observe(waited.as_secs_f64());
}
#[derive(Debug)]
pub struct BatchPermit {
#[allow(dead_code)]
shared: OwnedSemaphorePermit,
#[allow(dead_code)]
gate: OwnedSemaphorePermit,
}
#[derive(Debug)]
pub enum LanePermit {
Interactive(#[allow(dead_code)] OwnedSemaphorePermit),
Batch(#[allow(dead_code)] BatchPermit),
}
#[derive(Debug, Clone)]
pub struct ReservedSemaphore {
shared: Arc<Semaphore>,
batch_gate: Arc<Semaphore>,
name: &'static str,
}
impl ReservedSemaphore {
pub fn new(total: usize, reserved: usize, name: &'static str) -> Self {
let total = total.max(1);
let reserved = reserved.min(total - 1);
Self {
shared: Arc::new(Semaphore::new(total)),
batch_gate: Arc::new(Semaphore::new(total - reserved)),
name,
}
}
pub async fn acquire(&self, class: ScrapeClass) -> LanePermit {
let t0 = std::time::Instant::now();
let permit = match class {
ScrapeClass::Interactive => {
let shared = Semaphore::acquire_owned(self.shared.clone())
.await
.expect("shared reserved-lane semaphore never closed");
LanePermit::Interactive(shared)
}
ScrapeClass::Batch => {
let gate = Semaphore::acquire_owned(self.batch_gate.clone())
.await
.expect("batch_gate semaphore never closed");
let shared = Semaphore::acquire_owned(self.shared.clone())
.await
.expect("shared reserved-lane semaphore never closed");
LanePermit::Batch(BatchPermit { shared, gate })
}
};
record_lane_wait(self.name, class, t0.elapsed());
permit
}
pub fn available(&self) -> usize {
self.shared.available_permits()
}
pub fn batch_available(&self) -> usize {
self.batch_gate.available_permits()
}
}
#[derive(Debug, Clone)]
pub struct BatchGate {
gate: Arc<Semaphore>,
name: &'static str,
}
impl BatchGate {
pub fn new(total: usize, reserved: usize, name: &'static str) -> Self {
let total = total.max(1);
let reserved = reserved.min(total - 1);
Self {
gate: Arc::new(Semaphore::new((total - reserved).max(1))),
name,
}
}
pub async fn enter(&self, class: ScrapeClass) -> Option<OwnedSemaphorePermit> {
match class {
ScrapeClass::Interactive => None,
ScrapeClass::Batch => {
let t0 = std::time::Instant::now();
let permit = Semaphore::acquire_owned(self.gate.clone())
.await
.expect("batch render gate never closed");
record_lane_wait(self.name, class, t0.elapsed());
Some(permit)
}
}
}
pub fn available(&self) -> usize {
self.gate.available_permits()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn batch_gate_floored_at_one() {
for (total, reserved) in [(2, 1), (2, 5), (1, 0), (1, 9), (4, 4)] {
let rs = ReservedSemaphore::new(total, reserved, "test");
assert!(
rs.batch_available() >= 1,
"batch_gate must be >=1 for total={total} reserved={reserved}"
);
}
}
#[test]
fn reserved_zero_is_single_pool() {
let rs = ReservedSemaphore::new(8, 0, "test");
assert_eq!(rs.available(), 8);
assert_eq!(rs.batch_available(), 8);
}
#[tokio::test]
async fn interactive_reserve_survives_batch_saturation() {
let rs = ReservedSemaphore::new(4, 1, "test");
let mut held = Vec::new();
for _ in 0..3 {
held.push(rs.acquire(ScrapeClass::Batch).await);
}
assert_eq!(rs.batch_available(), 0);
let interactive = tokio::time::timeout(
std::time::Duration::from_millis(200),
rs.acquire(ScrapeClass::Interactive),
)
.await;
assert!(
interactive.is_ok(),
"interactive must acquire while batch_gate is saturated"
);
drop(held);
}
#[tokio::test]
async fn batch_gate_reserves_pool_slots_for_interactive() {
let g = BatchGate::new(4, 1, "test");
let mut held = Vec::new();
for _ in 0..3 {
held.push(g.enter(ScrapeClass::Batch).await);
}
assert_eq!(g.available(), 0, "gate exhausted after 3 batch holders");
assert!(g.enter(ScrapeClass::Interactive).await.is_none());
drop(held);
}
#[tokio::test]
async fn batch_gate_floored_never_zero() {
let g = BatchGate::new(1, 1, "test");
assert!(g.available() >= 1);
}
#[tokio::test]
async fn task_local_class_drives_lane_end_to_end() {
let rs = ReservedSemaphore::new(2, 1, "test");
let _batch = crate::scrape_class::REQUEST_CLASS
.scope(ScrapeClass::Batch, async {
rs.acquire(crate::current_scrape_class()).await
})
.await;
assert_eq!(rs.batch_available(), 0, "batch lane exhausted");
let got = crate::scrape_class::REQUEST_CLASS
.scope(ScrapeClass::Interactive, async {
tokio::time::timeout(
std::time::Duration::from_millis(200),
rs.acquire(crate::current_scrape_class()),
)
.await
})
.await;
assert!(
got.is_ok(),
"interactive lane must not be blocked by a saturated batch lane"
);
}
#[tokio::test]
async fn batch_blocked_when_gate_full_then_freed() {
let rs = ReservedSemaphore::new(2, 1, "test"); let first = rs.acquire(ScrapeClass::Batch).await;
let rs2 = rs.clone();
let waiter = tokio::spawn(async move { rs2.acquire(ScrapeClass::Batch).await });
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert!(!waiter.is_finished(), "second batch acquire should block");
drop(first);
let _second = tokio::time::timeout(std::time::Duration::from_millis(200), waiter)
.await
.expect("waiter should finish after release")
.expect("join ok");
}
}