#[cfg(test)]
mod tests;
use std::collections::HashMap;
use std::collections::hash_map::Entry as MapEntry;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::time::Duration;
use promptforge_core::CancelHandle;
use tokio::sync::oneshot;
use tokio::sync::{OwnedSemaphorePermit, Semaphore};
use tokio::task::{JoinError, JoinHandle};
use tokio::time::Instant;
use crate::config::ServerConfig;
use crate::result::{NO_TURNS, RunResult};
#[derive(Debug)]
#[must_use = "dropping the slot returns it, so a run that means to hold one must keep it"]
pub(crate) struct RunSlot {
_permit: OwnedSemaphorePermit,
}
#[derive(Debug)]
struct Finished {
at: Instant,
result: RunResult,
}
#[derive(Debug)]
struct Record {
prompt: String,
started: Instant,
cancel: CancelHandle,
finished: Option<Finished>,
}
#[derive(Debug)]
pub(crate) struct DuplicateRun;
struct CancelOnDrop {
registry: Arc<RunRegistry>,
run_id: String,
armed: bool,
}
impl CancelOnDrop {
fn disarm(&mut self) {
self.armed = false;
}
}
impl Drop for CancelOnDrop {
fn drop(&mut self) {
if self.armed {
self.registry.cancel(&self.run_id);
}
}
}
impl Record {
fn snapshot(&self, run_id: &str) -> RunResult {
match &self.finished {
Some(finished) => finished.result.clone(),
None => RunResult::running(run_id.to_owned(), &self.prompt, elapsed_ms(self.started)),
}
}
}
#[derive(Debug)]
pub(crate) struct RunRegistry {
admission: Arc<Semaphore>,
admission_timeout: Duration,
reply_deadline: Duration,
retain_completed: Duration,
records: Mutex<HashMap<String, Record>>,
}
impl RunRegistry {
#[must_use]
pub(crate) fn new(server: &ServerConfig) -> RunRegistry {
RunRegistry {
admission: Arc::new(Semaphore::new(server.max_concurrent_runs.get())),
admission_timeout: server.admission_timeout,
reply_deadline: server.reply_deadline,
retain_completed: server.retain_completed,
records: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub(crate) fn admission_timeout(&self) -> Duration {
self.admission_timeout
}
#[must_use]
pub(crate) fn retain_completed(&self) -> Duration {
self.retain_completed
}
pub(crate) async fn admit(&self) -> Option<RunSlot> {
let admission = Arc::clone(&self.admission);
let waited = tokio::time::timeout(self.admission_timeout, admission.acquire_owned()).await;
let Ok(Ok(permit)) = waited else {
tracing::warn!("no run slot came free: refusing the call, which can be retried");
return None;
};
Some(RunSlot { _permit: permit })
}
pub(crate) fn launch<F>(
self: &Arc<Self>,
run_id: String,
prompt: String,
cancel: CancelHandle,
result_tx: oneshot::Sender<RunResult>,
spawn_run: F,
) -> Result<(), DuplicateRun>
where
F: FnOnce() -> JoinHandle<RunResult>,
{
let mut records = self.records();
evict(&mut records, self.retain_completed);
let task = match records.entry(run_id.clone()) {
MapEntry::Occupied(_) => return Err(DuplicateRun),
MapEntry::Vacant(vacant) => {
vacant.insert(Record {
prompt: prompt.clone(),
started: Instant::now(),
cancel,
finished: None,
});
spawn_run()
}
};
drop(records);
let registry = Arc::clone(self);
let _supervisor = tokio::spawn(async move {
let terminal = match task.await {
Ok(result) => result,
Err(join) => {
let result = registry.unfinished(&run_id, &prompt, &join);
tracing::info!(
run_id = %run_id,
prompt = %prompt,
status = ?result.status(),
turns = result.turns(),
elapsed_ms = result.elapsed_ms(),
"a backgrounded run reached its terminal state"
);
result
}
};
registry.finished(&run_id, terminal.clone());
let _ = result_tx.send(terminal);
});
Ok(())
}
pub(crate) fn finished(&self, run_id: &str, result: RunResult) {
let mut records = self.records();
evict(&mut records, self.retain_completed);
if let Some(record) = records.get_mut(run_id)
&& record.finished.is_none()
{
record.finished = Some(Finished {
at: Instant::now(),
result,
});
}
}
#[must_use]
pub(crate) fn check(&self, run_id: &str) -> Option<RunResult> {
let mut records = self.records();
evict(&mut records, self.retain_completed);
records.get(run_id).map(|record| record.snapshot(run_id))
}
pub(crate) fn cancel(&self, run_id: &str) {
if let Some(record) = self.records().get(run_id) {
record.cancel.cancel();
}
}
pub(crate) async fn settle(
self: &Arc<Self>,
run_id: &str,
prompt: &str,
result_rx: oneshot::Receiver<RunResult>,
) -> RunResult {
let mut guard = CancelOnDrop {
registry: Arc::clone(self),
run_id: run_id.to_owned(),
armed: true,
};
match tokio::time::timeout(self.reply_deadline, result_rx).await {
Ok(Ok(result)) => {
guard.disarm();
result
}
Ok(Err(_closed)) => {
guard.disarm();
self.running_snapshot(run_id, prompt)
}
Err(_elapsed) => {
guard.disarm();
tracing::info!(
run_id = %run_id,
prompt = %prompt,
"the run outlived its call and is collectable by run id"
);
self.running_snapshot(run_id, prompt)
}
}
}
fn running_snapshot(&self, run_id: &str, prompt: &str) -> RunResult {
self.check(run_id)
.unwrap_or_else(|| RunResult::running(run_id.to_owned(), prompt, 0))
}
fn unfinished(&self, run_id: &str, prompt: &str, join: &JoinError) -> RunResult {
let elapsed = self
.records()
.get(run_id)
.map_or(0, |record| elapsed_ms(record.started));
RunResult::failed(
run_id.to_owned(),
prompt,
format!("the run did not finish: {join}"),
NO_TURNS,
elapsed,
)
}
fn records(&self) -> MutexGuard<'_, HashMap<String, Record>> {
self.records.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn evict(records: &mut HashMap<String, Record>, retain: Duration) {
let now = Instant::now();
let held = records.len();
records.retain(|_, record| match &record.finished {
Some(finished) => now.saturating_duration_since(finished.at) < retain,
None => true,
});
let evicted = held - records.len();
if evicted > 0 {
tracing::debug!(evicted, "evicted run record(s) past the retention window");
}
}
pub(crate) fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}