use std::{
collections::BTreeMap,
sync::{Arc, Mutex, MutexGuard, OnceLock, PoisonError},
time::Duration,
};
use connectrpc::ConnectError;
use polyc_state::{
error::{OutageReach, StateError},
feed,
id::OperationFamily,
journal, projection,
};
use prometheus::{IntCounterVec, IntGaugeVec, register_int_counter_vec, register_int_gauge_vec};
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use crate::error::WireOutcome;
pub const MAX_FAMILY_BLOCKING_SLOTS: usize = 8;
pub const REPLAY_BLOCKING_SLOTS: usize = 4;
pub(crate) async fn mutation<R, E>(
span: tracing::Span,
family: OperationFamily,
wait: Duration,
operation: impl FnOnce() -> Result<R, E> + Send + 'static,
) -> Result<R, ConnectError>
where
R: Send + 'static,
E: WireOutcome + Send + 'static,
{
run(span, family, wait, OutageReach::PossiblyApplied, operation).await
}
pub(crate) async fn read<R, E>(
span: tracing::Span,
family: OperationFamily,
wait: Duration,
operation: impl FnOnce() -> Result<R, E> + Send + 'static,
) -> Result<R, ConnectError>
where
R: Send + 'static,
E: WireOutcome + Send + 'static,
{
run(span, family, wait, OutageReach::NoDurableEffect, operation).await
}
fn slots() -> MutexGuard<'static, BTreeMap<OperationFamily, Arc<Semaphore>>> {
static SLOTS: OnceLock<Mutex<BTreeMap<OperationFamily, Arc<Semaphore>>>> = OnceLock::new();
SLOTS
.get_or_init(|| Mutex::new(BTreeMap::new()))
.lock()
.unwrap_or_else(PoisonError::into_inner)
}
fn slot_family(family: &OperationFamily) -> OperationFamily {
if *family == feed::family() || *family == projection::family() {
journal::family()
} else {
family.clone()
}
}
fn slot_count(key: &OperationFamily) -> usize {
if *key == journal::replay_family() {
REPLAY_BLOCKING_SLOTS
} else {
MAX_FAMILY_BLOCKING_SLOTS
}
}
fn family_slots(family: &OperationFamily) -> Arc<Semaphore> {
let key = slot_family(family);
slots()
.entry(key.clone())
.or_insert_with(|| Arc::new(Semaphore::new(slot_count(&key))))
.clone()
}
fn slots_in_use() -> &'static IntGaugeVec {
static METRIC: OnceLock<IntGaugeVec> = OnceLock::new();
METRIC.get_or_init(|| {
register_int_gauge_vec!(
"polychrome_state_blocking_pool_slots_in_use",
"Blocking-pool slots each State operation family is holding right now.",
&["family"]
)
.expect("the blocking-pool slots gauge registers once into the default registry")
})
}
fn slot_refusals() -> &'static IntCounterVec {
static METRIC: OnceLock<IntCounterVec> = OnceLock::new();
METRIC.get_or_init(|| {
register_int_counter_vec!(
"polychrome_state_blocking_pool_slot_refusals_total",
"State calls refused because no blocking-pool slot freed inside the call's deadline, \
by family.",
&["family"]
)
.expect("the slot-refusals counter registers once into the default registry")
})
}
struct HeldSlot {
_permit: OwnedSemaphorePermit,
family: OperationFamily,
}
impl HeldSlot {
fn new(permit: OwnedSemaphorePermit, family: &OperationFamily) -> Self {
slots_in_use().with_label_values(&[family.as_str()]).inc();
Self {
_permit: permit,
family: family.clone(),
}
}
}
impl Drop for HeldSlot {
fn drop(&mut self) {
slots_in_use()
.with_label_values(&[self.family.as_str()])
.dec();
}
}
fn refuse<E: WireOutcome>(family: OperationFamily, wait: Duration) -> ConnectError {
slot_refusals().with_label_values(&[family.as_str()]).inc();
tracing::warn!(
family = %family,
wait_ms = wait.as_millis(),
"a State call found no blocking-pool slot inside its deadline"
);
E::from(StateError::Unavailable {
family,
reach: OutageReach::NoDurableEffect,
})
.to_connect()
}
async fn run<R, E>(
span: tracing::Span,
family: OperationFamily,
wait: Duration,
reach: OutageReach,
operation: impl FnOnce() -> Result<R, E> + Send + 'static,
) -> Result<R, ConnectError>
where
R: Send + 'static,
E: WireOutcome + Send + 'static,
{
let slots = family_slots(&family);
let Ok(Ok(permit)) = tokio::time::timeout(wait, slots.acquire_owned()).await else {
return Err(refuse::<E>(family, wait));
};
let slot = HeldSlot::new(permit, &family);
tokio::task::spawn_blocking(move || {
let _slot = slot;
span.in_scope(operation)
})
.await
.map_err(|_| {
E::from(StateError::Unavailable {
family: family.clone(),
reach,
})
.to_connect()
})?
.map_err(|error| error.to_connect())
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use polyc_state::{
burn, claims, feed, id::OperationFamily, immutable, ingress, journal, model_attempt,
observation, passkey, persona_memory, projection, query_audit, spend, tasks, usage,
versioned, wallet,
};
use super::{slot_count, slot_family};
#[test]
fn the_family_slot_shares_fit_the_pool() {
let caller_families = [
versioned::family(),
burn::family(),
passkey::family(),
spend::family(),
tasks::family(),
usage::family(),
wallet::family(),
model_attempt::family(),
persona_memory::store::family(),
persona_memory::journal::family(),
claims::family(),
ingress::family(),
immutable::family(),
observation::family(),
query_audit::family(),
journal::family(),
feed::family(),
projection::family(),
journal::replay_family(),
];
let keys: BTreeSet<_> = caller_families.iter().map(slot_family).collect();
let names: BTreeSet<_> = keys.iter().map(OperationFamily::as_str).collect();
assert_eq!(
names,
BTreeSet::from([
"state.claims",
"state.immutable_object",
"state.ingress",
"state.observation",
"state.partition-journal",
"state.partition-journal-replay",
"state.persona-memory-journal",
"state.query_audit",
"state.versioned",
]),
"the distinct semaphore keys the services draw"
);
let ceilings: usize = keys.iter().map(slot_count).sum();
assert!(
ceilings < 128,
"{ceilings} slot ceilings plus the startup task must fit the pool"
);
}
}