use std::cell::RefCell;
use std::fmt;
use std::future::Future;
use std::pin::Pin;
use std::sync::{Arc, Mutex, MutexGuard, PoisonError};
use std::task::{Context, Poll};
use futures_core::Stream;
#[cfg(feature = "warning-events")]
use super::events::{self, Pending};
#[must_use = "an attempt scope holds the attempt's warning events until it is committed or discarded"]
pub(crate) struct AttemptScope {
buffer: Arc<Buffer>,
}
impl AttemptScope {
pub(crate) fn new() -> Self {
Self {
buffer: Arc::new(Buffer::default()),
}
}
pub(crate) fn instrument<F: Future>(&self, future: F) -> Instrumented<F> {
Instrumented {
inner: Box::pin(future),
buffer: Arc::clone(&self.buffer),
}
}
pub(crate) fn instrument_stream<S: Stream>(&self, stream: S) -> Instrumented<S> {
Instrumented {
inner: Box::pin(stream),
buffer: Arc::clone(&self.buffer),
}
}
pub(crate) fn commit(self) {
drop(self);
}
pub(crate) fn discard(self) {
drop(self.buffer.decide(Decision::Discarded));
}
}
impl Default for AttemptScope {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for AttemptScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let state = self.buffer.lock();
f.debug_struct("AttemptScope")
.field("decision", &state.decision)
.field("held", &state.held())
.finish()
}
}
impl Drop for AttemptScope {
fn drop(&mut self) {
let held = self.buffer.decide(Decision::Committed);
flush(held);
}
}
#[must_use = "futures and streams do nothing unless polled"]
pub(crate) struct Instrumented<T> {
inner: Pin<Box<T>>,
buffer: Arc<Buffer>,
}
impl<T> fmt::Debug for Instrumented<T> {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Instrumented").finish_non_exhaustive()
}
}
impl<F: Future> Future for Instrumented<F> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<F::Output> {
let this = self.get_mut();
let _entered = enter(&this.buffer);
this.inner.as_mut().poll(cx)
}
}
impl<S: Stream> Stream for Instrumented<S> {
type Item = S::Item;
fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<S::Item>> {
let this = self.get_mut();
let _entered = enter(&this.buffer);
this.inner.as_mut().poll_next(cx)
}
fn size_hint(&self) -> (usize, Option<usize>) {
self.inner.size_hint()
}
}
#[cfg(feature = "warning-events")]
pub(super) fn hold(pending: Pending) -> Option<Pending> {
let mut pending = Some(pending);
let mut discarded = None;
let _ = STACK.try_with(|stack| {
for buffer in stack.borrow().iter().rev() {
let mut state = buffer.lock();
match state.decision {
Decision::Open => {
state.events.extend(pending.take());
return;
}
Decision::Discarded => {
discarded = pending.take();
return;
}
Decision::Committed => {}
}
}
});
drop(discarded);
pending
}
fn flush(held: Held) {
#[cfg(feature = "warning-events")]
for pending in held {
if let Some(pending) = hold(pending) {
events::fire(&pending);
}
}
#[cfg(not(feature = "warning-events"))]
let _ = held;
}
#[derive(Default)]
struct Buffer {
state: Mutex<State>,
}
impl Buffer {
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
fn decide(&self, decision: Decision) -> Held {
let mut state = self.lock();
if state.decision != Decision::Open {
return Held::default();
}
state.decision = decision;
std::mem::take(&mut state.events)
}
}
#[derive(Default)]
struct State {
decision: Decision,
events: Held,
}
impl State {
fn held(&self) -> usize {
self.events.len()
}
}
#[cfg(feature = "warning-events")]
type Held = Vec<Pending>;
#[cfg(not(feature = "warning-events"))]
type Held = Vec<std::convert::Infallible>;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum Decision {
#[default]
Open,
Committed,
Discarded,
}
thread_local! {
static STACK: RefCell<Vec<Arc<Buffer>>> = const { RefCell::new(Vec::new()) };
}
fn enter(buffer: &Arc<Buffer>) -> Entered {
#[cfg(feature = "warning-events")]
let entered = STACK
.try_with(|stack| stack.borrow_mut().push(Arc::clone(buffer)))
.is_ok();
#[cfg(not(feature = "warning-events"))]
let entered = {
let _ = buffer;
false
};
Entered { entered }
}
struct Entered {
entered: bool,
}
impl Drop for Entered {
fn drop(&mut self) {
if self.entered {
let _ = STACK.try_with(|stack| stack.borrow_mut().pop());
}
}
}
#[cfg(all(test, feature = "warning-events"))]
mod tests {
use std::time::Duration;
use super::*;
use crate::mock::{MockProvider, MockResponse};
use crate::warning::capture::{Captured, captured, captured_on_threads};
use crate::wrappers::{FallbackProvider, RetryPolicy, RetryingProvider};
use crate::{
CapabilitySupport, ChatCapability, ChatProvider, ChatRequest, ChatResponse, Error,
ProviderIdentity, ReasoningEffort, Result, Subject, Warning, WarningKind,
};
fn coded(text: &'static str) -> Warning {
Warning::new(WarningKind::Other, Subject::Response, text).with_code("other.scope_test")
}
fn emit(model: &str, warning: &Warning) {
let mut warning = warning.clone();
crate::warning::emit("p", model, &mut warning);
}
fn for_model<'a>(events: &'a [Captured], model: &str) -> Vec<&'a Captured> {
events
.iter()
.filter(|event| event.model.as_deref() == Some(model))
.collect()
}
fn messages<'a>(events: &[&'a Captured]) -> Vec<&'a str> {
events.iter().map(|event| event.message.as_str()).collect()
}
fn levels(events: &[&Captured]) -> (usize, usize) {
let count = |level| events.iter().filter(|e| e.level == level).count();
(count(tracing::Level::WARN), count(tracing::Level::DEBUG))
}
#[test]
fn a_scope_holds_events_until_it_is_decided() {
let (held_until_commit, events) = captured(async {
let scope = AttemptScope::new();
scope
.instrument(async { emit("scope-discard", &coded("held")) })
.await;
let mut late = scope.instrument_stream(futures_util::stream::once(async {
emit("scope-discard", &coded("late"));
}));
scope.discard();
futures_util::StreamExt::next(&mut late).await;
let scope = AttemptScope::new();
scope
.instrument(async { emit("scope-commit", &coded("first")) })
.await;
let held = scope.buffer.lock().held();
let late = scope.instrument(async { emit("scope-commit", &coded("after")) });
scope.commit();
late.await;
let scope = AttemptScope::new();
scope
.instrument(async { emit("scope-drop", &coded("dropped")) })
.await;
drop(scope);
held
});
assert_eq!(held_until_commit, 1, "held until the commit");
assert!(for_model(&events, "scope-discard").is_empty());
assert_eq!(
messages(&for_model(&events, "scope-commit")),
["first", "after"]
);
assert_eq!(messages(&for_model(&events, "scope-drop")), ["dropped"]);
}
#[test]
fn nested_scopes_flush_into_the_enclosing_one() {
let ((), events) = captured(async {
let outer = AttemptScope::new();
outer
.instrument(async {
let inner = AttemptScope::new();
inner
.instrument(async { emit("nested", &coded("inner")) })
.await;
inner.commit();
})
.await;
outer.discard();
let outer = AttemptScope::new();
outer
.instrument(async {
let inner = AttemptScope::new();
inner
.instrument(async { emit("nested", &coded("inner")) })
.await;
inner.commit();
})
.await;
outer.commit();
});
let nested = for_model(&events, "nested");
assert_eq!(levels(&nested), (1, 0), "{nested:?}");
assert!(nested[0].repeats);
}
struct ThreeTries<P>(P);
impl<P: ProviderIdentity> ProviderIdentity for ThreeTries<P> {
fn provider_name(&self) -> &str {
self.0.provider_name()
}
fn reads_namespace(&self, namespace: &str) -> bool {
self.0.reads_namespace(namespace)
}
}
impl<P: ChatProvider> ChatProvider for ThreeTries<P> {
type Stream = P::Stream;
async fn chat(&self, request: &ChatRequest) -> Result<ChatResponse> {
let mut attempt = 1;
loop {
let scope = AttemptScope::new();
let result = scope.instrument(self.0.chat(request)).await;
match result {
Err(error) if error.is_retryable() && attempt < 3 => {
scope.discard();
attempt += 1;
tokio::task::yield_now().await;
}
result => {
scope.commit();
return result;
}
}
}
}
async fn chat_stream(&self, request: &ChatRequest) -> Result<Self::Stream> {
self.0.chat_stream(request).await
}
fn chat_capability(&self, model: &str, capability: ChatCapability) -> CapabilitySupport {
self.0.chat_capability(model, capability)
}
}
fn flaky(name: &'static str) -> MockProvider {
let delay = Duration::from_millis(2);
MockProvider::new([
MockResponse::delayed(delay, Error::timeout("first")),
MockResponse::delayed(delay, Error::timeout("second")),
MockResponse::delayed(delay, ChatResponse::default()),
])
.with_provider_name(name)
.with_chat_capability(
ChatCapability::ReasoningConfig,
CapabilitySupport::Unsupported,
)
}
fn request(model: &str) -> ChatRequest {
ChatRequest::new(model)
.user("q")
.reasoning(ReasoningEffort::High)
}
fn fast() -> RetryPolicy {
RetryPolicy::new()
.base_delay(Duration::ZERO)
.max_delay(Duration::ZERO)
.no_budget()
}
#[test]
fn retries_emit_once_on_a_multi_thread_runtime() {
let ((), events) = captured_on_threads("mt-", async {
let mut calls = Vec::new();
for call in 0..16 {
calls.push(tokio::spawn(async move {
let provider = ThreeTries(flaky("three-tries"));
provider.chat(&request(&format!("mt-tries-{call}"))).await
}));
calls.push(tokio::spawn(async move {
let provider = ThreeTries(RetryingProvider::new(
flaky("nested"),
fast().max_attempts(2),
));
provider.chat(&request(&format!("mt-nested-{call}"))).await
}));
calls.push(tokio::spawn(async move {
let a = MockProvider::new([
MockResponse::delayed(Duration::from_millis(1), Error::timeout("a")),
MockResponse::delayed(Duration::from_millis(1), Error::timeout("a")),
])
.with_provider_name("a");
let stack = RetryingProvider::new(
FallbackProvider::new(
RetryingProvider::new(a, fast().max_attempts(2)),
RetryingProvider::new(flaky("b"), fast()),
),
fast(),
);
stack.chat(&request(&format!("mt-chain-{call}"))).await
}));
}
for call in calls {
call.await.expect("no panic").expect("a response");
}
});
for call in 0..16 {
for model in [format!("mt-tries-{call}"), format!("mt-nested-{call}")] {
let model_events = for_model(&events, &model);
let hints = model_events
.iter()
.filter(|e| e.target == "allama::warning::unsupported")
.count();
assert_eq!(hints, 1, "{model}: {model_events:?}");
}
let chain = for_model(&events, &format!("mt-chain-{call}"));
let mut targets: Vec<_> = chain.iter().map(|e| e.target.as_str()).collect();
targets.sort_unstable();
assert_eq!(
targets,
["allama::warning::fallback", "allama::warning::unsupported"],
"{chain:?}"
);
}
assert_eq!(levels(&for_model(&events, "mt-tries-0")), (1, 0));
}
}