use std::sync::Arc;
use bevy_ecs::entity::Entity;
use leviath_providers::{InferenceRequest, Provider, ProviderError};
use tokio::sync::Notify;
use tokio::sync::mpsc::UnboundedSender;
use crate::inference_pool::InferencePermit;
pub struct CompactionJob {
pub entity: Entity,
pub provider: Arc<dyn Provider>,
pub requests: Vec<(String, InferenceRequest)>,
pub permit: InferencePermit,
}
pub struct CompactionOutcome {
pub entity: Entity,
pub result: Result<Vec<(String, String)>, ProviderError>,
}
pub async fn run_compaction_job(
job: CompactionJob,
deadline: std::time::Duration,
results: UnboundedSender<CompactionOutcome>,
wake: Arc<Notify>,
) {
let CompactionJob {
entity,
provider,
requests,
permit,
} = job;
let mut summaries = Vec::new();
let mut result = Ok(());
for (region, request) in requests {
match tokio::time::timeout(deadline, provider.infer(request)).await {
Ok(Ok(response)) => summaries.push((region, response.content)),
Ok(Err(e)) => {
result = Err(e);
break;
}
Err(_) => {
result = Err(leviath_providers::ProviderError::Other(format!(
"compaction exceeded the {}s deadline and was aborted to free \
the pool slot",
deadline.as_secs()
)));
break;
}
}
}
drop(permit);
let outcome = CompactionOutcome {
entity,
result: result.map(|()| summaries),
};
let _ = results.send(outcome);
wake.notify_one();
}
#[cfg(test)]
mod tests {
use super::*;
use crate::inference_pool::{InferencePoolConfig, InferencePools};
use leviath_providers::{FinishReason, InferenceResponse, ModelCapabilities, TokenUsage};
use std::sync::Mutex;
use tokio::sync::mpsc;
fn request() -> InferenceRequest {
InferenceRequest {
system: vec![],
messages: vec![],
model: "m".to_string(),
max_tokens: 100,
temperature: 0.0,
tools: vec![],
extra: serde_json::Value::Null,
request_timeout_secs: None,
}
}
struct Script {
out: Mutex<std::collections::VecDeque<Result<String, String>>>,
}
#[async_trait::async_trait]
impl Provider for Script {
async fn infer(
&self,
_req: InferenceRequest,
) -> leviath_providers::Result<InferenceResponse> {
match self.out.lock().unwrap().pop_front() {
Some(Ok(text)) => Ok(InferenceResponse {
content: text,
tool_calls: vec![],
tokens_used: TokenUsage {
prompt_tokens: 1,
completion_tokens: 1,
total_tokens: 2,
cached_tokens: 0,
cache_write_tokens: 0,
},
finish_reason: FinishReason::Complete,
}),
Some(Err(m)) => Err(ProviderError::Other(m)),
None => Err(ProviderError::Other("exhausted".to_string())),
}
}
async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1
}
fn max_context_tokens(&self, _m: &str) -> usize {
100_000
}
fn name(&self) -> &str {
"script"
}
fn capabilities(&self, _m: &str) -> ModelCapabilities {
ModelCapabilities::default()
}
}
struct Hang;
#[async_trait::async_trait]
impl Provider for Hang {
async fn infer(
&self,
_req: InferenceRequest,
) -> leviath_providers::Result<InferenceResponse> {
std::future::pending().await
}
async fn count_tokens(&self, _t: &str, _m: &str) -> usize {
1
}
fn max_context_tokens(&self, _m: &str) -> usize {
100_000
}
fn name(&self) -> &str {
"hang"
}
fn capabilities(&self, _m: &str) -> ModelCapabilities {
ModelCapabilities::default()
}
}
#[tokio::test]
async fn deadline_frees_the_slot_when_the_provider_hangs() {
assert_eq!(Hang.count_tokens("t", "m").await, 1);
assert_eq!(Hang.max_context_tokens("m"), 100_000);
assert_eq!(Hang.name(), "hang");
let _ = Hang.capabilities("m");
let (tx, mut rx) = mpsc::unbounded_channel();
let wake = Arc::new(tokio::sync::Notify::new());
run_compaction_job(
job(Arc::new(Hang), vec!["a"]),
std::time::Duration::from_millis(5),
tx,
wake,
)
.await;
let outcome = rx.recv().await.expect("an outcome is always reported");
let err = outcome.result.expect_err("the deadline must surface");
assert!(err.to_string().contains("deadline"), "{err}");
}
fn job(provider: Arc<dyn Provider>, regions: Vec<&str>) -> CompactionJob {
let pools = InferencePools::new(InferencePoolConfig::new());
CompactionJob {
entity: Entity::from_raw_u32(3)
.expect("a small literal index is always a valid entity id"),
provider,
requests: regions
.into_iter()
.map(|r| (r.to_string(), request()))
.collect(),
permit: pools.try_acquire("m").expect("free"),
}
}
#[tokio::test]
async fn runs_all_regions_and_reports_summaries() {
let (tx, mut rx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
let provider = Arc::new(Script {
out: Mutex::new(vec![Ok("s1".to_string()), Ok("s2".to_string())].into()),
});
run_compaction_job(
job(provider, vec!["a", "b"]),
std::time::Duration::from_secs(60),
tx,
wake.clone(),
)
.await;
let outcome = rx.try_recv().unwrap();
assert_eq!(
outcome.entity,
Entity::from_raw_u32(3).expect("a small literal index is always a valid entity id")
);
let summaries = outcome.result.unwrap();
assert_eq!(
summaries,
vec![
("a".to_string(), "s1".to_string()),
("b".to_string(), "s2".to_string()),
]
);
wake.notified().await; }
#[tokio::test]
async fn stops_at_first_error() {
let (tx, mut rx) = mpsc::unbounded_channel();
let wake = Arc::new(Notify::new());
let provider = Arc::new(Script {
out: Mutex::new(vec![Err("boom".to_string())].into()),
});
run_compaction_job(
job(provider, vec!["a", "b"]),
std::time::Duration::from_secs(60),
tx,
wake,
)
.await;
let outcome = rx.try_recv().unwrap();
assert!(outcome.result.is_err());
}
#[tokio::test]
async fn survives_dropped_receiver() {
let (tx, rx) = mpsc::unbounded_channel();
drop(rx);
let wake = Arc::new(Notify::new());
let provider = Arc::new(Script {
out: Mutex::new(vec![Ok("s".to_string())].into()),
});
run_compaction_job(
job(provider, vec!["a"]),
std::time::Duration::from_secs(60),
tx,
wake,
)
.await;
}
#[tokio::test]
async fn script_metadata_is_exercised() {
let p = Script {
out: Mutex::new(std::collections::VecDeque::new()),
};
assert_eq!(p.name(), "script");
assert_eq!(p.count_tokens("t", "m").await, 1);
assert_eq!(p.max_context_tokens("m"), 100_000);
let _ = p.capabilities("m");
}
}