use crate::utils::ChainError;
use async_trait::async_trait;
use std::sync::{Arc, LazyLock, OnceLock};
use std::time::Duration;
use tokio::sync::Semaphore;
use tracing::warn;
pub const DEFAULT_MAX_CONCURRENT_PRICING_JOBS: usize = 4;
static PRICING_PERMITS: LazyLock<Semaphore> = LazyLock::new(|| Semaphore::new(configured_jobs()));
#[must_use]
pub fn configured_jobs() -> usize {
let raw = super::env::read_var("OCS_MAX_CONCURRENT_PRICING_JOBS");
raw.as_deref()
.and_then(|raw| raw.trim().parse::<usize>().ok())
.filter(|permits| *permits >= 1)
.unwrap_or_else(|| {
if raw.is_some() {
warn!(
default = DEFAULT_MAX_CONCURRENT_PRICING_JOBS,
"invalid OCS_MAX_CONCURRENT_PRICING_JOBS; falling back to the default"
);
}
DEFAULT_MAX_CONCURRENT_PRICING_JOBS
})
}
#[async_trait]
pub trait SharedPricingGate: Send + Sync {
async fn acquire(&self) -> Option<String>;
async fn renew(&self, token: &str) -> bool;
fn renewal_interval(&self) -> Duration {
Duration::from_secs(30)
}
async fn release(&self, token: &str);
}
static SHARED_GATE: OnceLock<Arc<dyn SharedPricingGate>> = OnceLock::new();
pub fn install_shared_gate(gate: Arc<dyn SharedPricingGate>) -> Result<(), ChainError> {
SHARED_GATE.set(gate).map_err(|_| {
ChainError::Internal("the shared pricing gate is already installed".to_string())
})
}
pub async fn admit_blocking<T, F>(job: F) -> Result<T, ChainError>
where
F: FnOnce() -> Result<T, ChainError> + Send + 'static,
T: Send + 'static,
{
let permit = PRICING_PERMITS.acquire().await.map_err(|error| {
ChainError::Internal(format!("the pricing admission gate is closed: {error}"))
})?;
let lease = match SHARED_GATE.get() {
Some(gate) => gate.acquire().await,
None => None,
};
let (answer, wait) = tokio::sync::oneshot::channel();
let gate = SHARED_GATE.get().cloned();
tokio::spawn(async move {
let outcome = supervise(job, gate.as_ref(), lease.as_deref()).await;
drop(permit);
let _ = answer.send(outcome);
});
match wait.await {
Ok(outcome) => outcome?,
Err(error) => Err(ChainError::Internal(format!(
"a pricing job did not report back: {error}"
))),
}
}
async fn supervise<T, F>(
job: F,
gate: Option<&Arc<dyn SharedPricingGate>>,
lease: Option<&str>,
) -> Result<Result<T, ChainError>, ChainError>
where
F: FnOnce() -> Result<T, ChainError> + Send + 'static,
T: Send + 'static,
{
let mut running = tokio::task::spawn_blocking(job);
let outcome = match (gate, lease) {
(Some(gate), Some(token)) => {
let interval = gate.renewal_interval();
let mut renewals =
tokio::time::interval_at(tokio::time::Instant::now() + interval, interval);
loop {
tokio::select! {
finished = &mut running => break finished,
_ = renewals.tick() => {
if !gate.renew(token).await {
warn!(
"a running pricing job's deployment-wide lease is gone; the \
bound may be exceeded until it finishes"
);
}
}
}
}
}
_ => (&mut running).await,
};
if let (Some(gate), Some(token)) = (gate, lease) {
gate.release(token).await;
}
outcome.map_err(|error| ChainError::Internal(format!("a pricing job did not finish: {error}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_the_default_matches_the_documentation() {
assert_eq!(DEFAULT_MAX_CONCURRENT_PRICING_JOBS, 4);
}
#[tokio::test]
async fn test_a_job_runs_under_the_bound() {
match admit_blocking(|| Ok(7_usize)).await {
Ok(value) => assert_eq!(value, 7),
Err(error) => panic!("the job must run: {error}"),
}
}
#[tokio::test]
async fn test_a_job_error_is_propagated() {
let outcome: Result<usize, ChainError> =
admit_blocking(|| Err(ChainError::Internal("the job failed".to_string()))).await;
match outcome {
Err(ChainError::Internal(message)) => assert_eq!(message, "the job failed"),
other => panic!("the job's own error must survive, got {other:?}"),
}
}
#[tokio::test]
async fn test_a_job_waits_when_every_permit_is_held() {
let held = match PRICING_PERMITS
.acquire_many(u32::try_from(DEFAULT_MAX_CONCURRENT_PRICING_JOBS).unwrap_or(1))
.await
{
Ok(permits) => permits,
Err(error) => panic!("the semaphore must hand out its permits: {error}"),
};
let mut job = Box::pin(admit_blocking(|| Ok(1_usize)));
match futures::future::select(
&mut job,
Box::pin(tokio::time::sleep(std::time::Duration::from_millis(50))),
)
.await
{
futures::future::Either::Left((outcome, _)) => {
panic!("the job must not start while the bound is full, got {outcome:?}")
}
futures::future::Either::Right(((), _)) => {}
}
drop(held);
match job.await {
Ok(value) => assert_eq!(value, 1),
Err(error) => panic!("the job must run once a permit frees: {error}"),
}
}
struct RecordingGate {
renewals: Arc<std::sync::atomic::AtomicUsize>,
released: Arc<std::sync::atomic::AtomicUsize>,
}
#[async_trait]
impl SharedPricingGate for RecordingGate {
async fn acquire(&self) -> Option<String> {
Some("token".to_string())
}
async fn renew(&self, _token: &str) -> bool {
self.renewals
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
true
}
fn renewal_interval(&self) -> Duration {
Duration::from_millis(20)
}
async fn release(&self, _token: &str) {
self.released
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
}
#[tokio::test]
async fn test_a_running_job_keeps_its_lease_alive() {
let renewals = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let released = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let gate: Arc<dyn SharedPricingGate> = Arc::new(RecordingGate {
renewals: Arc::clone(&renewals),
released: Arc::clone(&released),
});
let outcome = supervise(
|| {
std::thread::sleep(Duration::from_millis(120));
Ok(3_usize)
},
Some(&gate),
Some("token"),
)
.await;
match outcome {
Ok(Ok(value)) => assert_eq!(value, 3),
other => panic!("the job must run: {other:?}"),
}
assert!(
renewals.load(std::sync::atomic::Ordering::SeqCst) >= 2,
"a job outliving several renewal intervals must have renewed its lease"
);
assert_eq!(
released.load(std::sync::atomic::Ordering::SeqCst),
1,
"the lease must be given back exactly once"
);
}
#[tokio::test]
async fn test_a_panicking_job_releases_its_lease() {
let renewals = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let released = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let gate: Arc<dyn SharedPricingGate> = Arc::new(RecordingGate {
renewals: Arc::clone(&renewals),
released: Arc::clone(&released),
});
let outcome: Result<Result<usize, ChainError>, ChainError> =
supervise(|| panic!("the job blew up"), Some(&gate), Some("token")).await;
assert!(
matches!(outcome, Err(ChainError::Internal(_))),
"a panicking job must surface as an internal error"
);
assert_eq!(
released.load(std::sync::atomic::Ordering::SeqCst),
1,
"a panic must not leave a lease held until it expires"
);
}
#[tokio::test]
async fn test_a_cancelled_caller_holds_the_bound_until_its_job_ends() {
let (started, mut was_started) = tokio::sync::oneshot::channel::<()>();
let (finish, may_finish) = std::sync::mpsc::channel::<()>();
let mut job = Box::pin(admit_blocking(move || {
let _ = started.send(());
let _ = may_finish.recv();
Ok(1_usize)
}));
let mut waited = Duration::ZERO;
while was_started.try_recv().is_err() {
match futures::future::select(
&mut job,
Box::pin(tokio::time::sleep(Duration::from_millis(10))),
)
.await
{
futures::future::Either::Left((outcome, _)) => {
panic!("the job cannot finish before it is let go: {outcome:?}")
}
futures::future::Either::Right(((), _)) => {}
}
waited += Duration::from_millis(10);
assert!(
waited < Duration::from_secs(5),
"the job never started, so there is nothing to cancel"
);
}
drop(job);
let during = PRICING_PERMITS.available_permits();
assert!(
during < configured_jobs(),
"a cancelled caller must not have given the permit back while its job runs"
);
let _ = finish.send(());
let mut waited = Duration::ZERO;
while PRICING_PERMITS.available_permits() <= during && waited < Duration::from_secs(5) {
tokio::time::sleep(Duration::from_millis(10)).await;
waited += Duration::from_millis(10);
}
assert!(
PRICING_PERMITS.available_permits() > during,
"the permit must come back once the job actually finishes"
);
}
}