use std::collections::HashMap;
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use tracing::warn;
use crate::config::{BudgetCfg, LlmCfg};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetUnit {
Tokens,
UsdMicros,
}
impl BudgetUnit {
fn parse(s: &str) -> Result<BudgetUnit> {
match s.trim().to_ascii_lowercase().as_str() {
"tokens" | "token" | "" => Ok(BudgetUnit::Tokens),
"usd" | "usd_micros" | "cost" => Ok(BudgetUnit::UsdMicros),
other => anyhow::bail!("invalid llm budget unit {other:?} (expected tokens|usd)"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetScope {
Global,
PerKey,
PerModel,
PerTeam,
}
impl BudgetScope {
fn parse(s: &str) -> Result<BudgetScope> {
match s.trim().to_ascii_lowercase().as_str() {
"global" | "" => Ok(BudgetScope::Global),
"key" | "per_key" | "per-key" => Ok(BudgetScope::PerKey),
"model" | "per_model" | "per-model" => Ok(BudgetScope::PerModel),
"team" | "per_team" | "per-team" | "tag" => Ok(BudgetScope::PerTeam),
other => {
anyhow::bail!("invalid llm budget scope {other:?} (expected global|key|model|team)")
}
}
}
pub fn label(&self) -> &'static str {
match self {
BudgetScope::Global => "global",
BudgetScope::PerKey => "key",
BudgetScope::PerModel => "model",
BudgetScope::PerTeam => "team",
}
}
}
#[derive(Debug, Clone)]
struct Budget {
name: String,
scope: BudgetScope,
unit: BudgetUnit,
limit: u64,
window_secs: u64,
}
impl Budget {
fn build(cfg: &BudgetCfg) -> Result<Budget> {
anyhow::ensure!(
!cfg.name.trim().is_empty(),
"llm budget name must not be empty"
);
let unit = BudgetUnit::parse(&cfg.unit)?;
let scope = BudgetScope::parse(&cfg.scope)?;
let window_secs = crate::config::parse_duration(&cfg.window)
.with_context(|| format!("llm budget {:?} window", cfg.name))?
.as_secs();
anyhow::ensure!(
window_secs > 0,
"llm budget {:?} window must be > 0",
cfg.name
);
anyhow::ensure!(
cfg.limit.is_finite() && cfg.limit > 0.0,
"llm budget {:?} limit must be > 0",
cfg.name
);
let limit = match unit {
BudgetUnit::Tokens => cfg.limit.round() as u64,
BudgetUnit::UsdMicros => (cfg.limit * 1_000_000.0).round() as u64,
};
anyhow::ensure!(
limit > 0,
"llm budget {:?} limit rounds to zero; use a larger value",
cfg.name
);
Ok(Budget {
name: cfg.name.clone(),
scope,
unit,
limit,
window_secs,
})
}
fn key(&self, prefix: &str, dims: &Dims, now_secs: u64) -> String {
let dim = match self.scope {
BudgetScope::Global => "_",
BudgetScope::PerKey => dims.principal.unwrap_or("_anon"),
BudgetScope::PerModel => dims.model,
BudgetScope::PerTeam => dims.team.unwrap_or("_none"),
};
let window = now_secs / self.window_secs;
format!("{prefix}:budget:{}:{dim}:{window}", self.name)
}
fn amount(&self, tokens: u64, cost_micros: u64) -> u64 {
match self.unit {
BudgetUnit::Tokens => tokens,
BudgetUnit::UsdMicros => cost_micros,
}
}
fn ttl_ms(&self) -> u64 {
self.window_secs.saturating_mul(2_000).max(1)
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Dims<'a> {
pub principal: Option<&'a str>,
pub model: &'a str,
pub team: Option<&'a str>,
}
fn would_reserve(used: u64, amount: u64, limit: u64) -> bool {
used.saturating_add(amount) <= limit
}
#[derive(Debug, Clone, Copy)]
struct ReserveOutcome {
admitted: bool,
used_after: u64,
}
enum Store {
Memory(MemoryStore),
Redis(Box<RedisStore>),
}
impl Store {
async fn reserve(
&self,
key: &str,
amount: u64,
limit: u64,
ttl_ms: u64,
) -> Result<ReserveOutcome> {
match self {
Store::Memory(s) => Ok(s.reserve(key, amount, limit, ttl_ms)),
Store::Redis(s) => s.reserve(key, amount, limit, ttl_ms).await,
}
}
async fn reconcile(&self, key: &str, delta: i64, ttl_ms: u64, marker: &str) -> bool {
match self {
Store::Memory(s) => {
s.reconcile(key, delta, ttl_ms, marker);
true
}
Store::Redis(s) => match s.reconcile(key, delta, ttl_ms, marker).await {
Ok(()) => true,
Err(e) => {
warn!(error = %format!("{e:#}"), "llm budget reconcile failed after retries — counter has drifted (alert on edgeguard_llm_budget_reconcile_failures_total)");
false
}
},
}
}
}
#[derive(Default)]
struct MemoryStore {
used: Mutex<HashMap<String, (u64, Instant)>>,
settled: Mutex<HashMap<String, Instant>>,
}
impl MemoryStore {
fn reserve(&self, key: &str, amount: u64, limit: u64, ttl_ms: u64) -> ReserveOutcome {
let now = Instant::now();
let expires = now + Duration::from_millis(ttl_ms);
let mut map = self.used.lock().expect("budget store mutex poisoned");
map.retain(|_, (_, exp)| *exp > now);
let used = map.get(key).map(|(v, _)| *v).unwrap_or(0);
if would_reserve(used, amount, limit) {
let used_after = used.saturating_add(amount);
map.insert(key.to_string(), (used_after, expires));
ReserveOutcome {
admitted: true,
used_after,
}
} else {
ReserveOutcome {
admitted: false,
used_after: used,
}
}
}
fn reconcile(&self, key: &str, delta: i64, ttl_ms: u64, marker: &str) {
let now = Instant::now();
{
let mut seen = self.settled.lock().expect("budget settled mutex poisoned");
seen.retain(|_, exp| *exp > now);
if seen.contains_key(marker) {
return;
}
seen.insert(
marker.to_string(),
now + Duration::from_millis(MARKER_TTL_MS),
);
}
let expires = now + Duration::from_millis(ttl_ms);
let mut map = self.used.lock().expect("budget store mutex poisoned");
map.retain(|_, (_, exp)| *exp > now);
let used = map.get(key).map(|(v, _)| *v).unwrap_or(0) as i64;
let next = (used + delta).max(0) as u64;
map.insert(key.to_string(), (next, expires));
}
}
const RESERVE_LUA: &str = r#"
local used = tonumber(redis.call('GET', KEYS[1]) or '0')
local amount = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local ttl = tonumber(ARGV[3])
if used + amount > limit then
return {0, used}
end
local newv = redis.call('INCRBY', KEYS[1], amount)
redis.call('PEXPIRE', KEYS[1], ttl)
return {1, newv}
"#;
const RECONCILE_LUA: &str = r#"
if redis.call('SET', KEYS[2], '1', 'NX', 'PX', tonumber(ARGV[3])) == false then
return tonumber(redis.call('GET', KEYS[1]) or '0')
end
local new = redis.call('INCRBY', KEYS[1], tonumber(ARGV[1]))
if new < 0 then
redis.call('SET', KEYS[1], 0)
new = 0
end
redis.call('PEXPIRE', KEYS[1], tonumber(ARGV[2]))
return new
"#;
const MARKER_TTL_MS: u64 = 300_000;
const RECONCILE_ATTEMPTS: u32 = 3;
const RECONCILE_BACKOFF: Duration = Duration::from_millis(25);
async fn retry_async<F, Fut, T>(attempts: u32, backoff: Duration, mut op: F) -> Result<T>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T>>,
{
let attempts = attempts.max(1);
let mut last: Option<anyhow::Error> = None;
for i in 0..attempts {
match op().await {
Ok(v) => return Ok(v),
Err(e) => {
last = Some(e);
if i + 1 < attempts {
tokio::time::sleep(backoff).await;
}
}
}
}
Err(last.expect("retry_async ran at least one attempt"))
}
struct RedisStore {
client: redis::Client,
conn: tokio::sync::OnceCell<redis::aio::ConnectionManager>,
reserve: redis::Script,
reconcile: redis::Script,
}
impl RedisStore {
fn new(url: &str) -> Result<RedisStore> {
anyhow::ensure!(
!url.trim().is_empty(),
"llm.redis_url is required when llm.store = \"redis\""
);
let client = redis::Client::open(url)
.with_context(|| format!("opening redis client for {url:?} (llm.redis_url)"))?;
Ok(RedisStore {
client,
conn: tokio::sync::OnceCell::new(),
reserve: redis::Script::new(RESERVE_LUA),
reconcile: redis::Script::new(RECONCILE_LUA),
})
}
async fn manager(&self) -> Result<redis::aio::ConnectionManager> {
self.conn
.get_or_try_init(|| redis::aio::ConnectionManager::new(self.client.clone()))
.await
.context("connecting to redis llm-budget store")
.cloned()
}
async fn reserve(
&self,
key: &str,
amount: u64,
limit: u64,
ttl_ms: u64,
) -> Result<ReserveOutcome> {
let mut conn = self.manager().await?;
let (admitted, used_after): (i64, i64) = self
.reserve
.key(key)
.arg(amount)
.arg(limit)
.arg(ttl_ms)
.invoke_async(&mut conn)
.await
.context("evaluating redis budget reserve script")?;
Ok(ReserveOutcome {
admitted: admitted == 1,
used_after: used_after.max(0) as u64,
})
}
async fn reconcile(&self, key: &str, delta: i64, ttl_ms: u64, marker: &str) -> Result<()> {
retry_async(RECONCILE_ATTEMPTS, RECONCILE_BACKOFF, || async {
let mut conn = self.manager().await?;
let _: i64 = self
.reconcile
.key(key)
.key(marker)
.arg(delta)
.arg(ttl_ms)
.arg(MARKER_TTL_MS)
.invoke_async(&mut conn)
.await
.context("evaluating redis budget reconcile script")?;
Ok(())
})
.await
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Spend {
pub tokens: u64,
pub cost_micros: u64,
}
#[derive(Debug, Clone)]
pub struct Observation {
pub name: String,
pub consumed_ratio: f64,
}
#[derive(Debug, Clone)]
pub struct Denial {
pub name: String,
pub scope: BudgetScope,
pub unit: BudgetUnit,
pub rollback_failures: usize,
}
#[derive(Debug, Default)]
pub struct Reservation {
id: String,
held: Vec<(String, u64, usize)>,
observations: Vec<Observation>,
rollback_failures: usize,
}
impl Reservation {
pub fn is_empty(&self) -> bool {
self.held.is_empty()
}
pub fn observations(&self) -> &[Observation] {
&self.observations
}
pub fn rollback_failures(&self) -> usize {
self.rollback_failures
}
}
#[derive(Debug)]
pub enum Reserved {
Ok(Reservation),
Denied(Denial),
Error { rollback_failures: usize },
}
pub struct BudgetEngine {
budgets: Vec<Budget>,
store: Store,
prefix: String,
fail_open: bool,
}
impl BudgetEngine {
pub fn build(cfg: &LlmCfg) -> Result<Option<BudgetEngine>> {
if cfg.budgets.is_empty() {
return Ok(None);
}
let store = match crate::limiter::StoreMode::parse(&cfg.store)? {
crate::limiter::StoreMode::Local | crate::limiter::StoreMode::Memory => {
Store::Memory(MemoryStore::default())
}
crate::limiter::StoreMode::Redis => {
Store::Redis(Box::new(RedisStore::new(&cfg.redis_url)?))
}
};
let budgets = cfg
.budgets
.iter()
.map(Budget::build)
.collect::<Result<Vec<_>>>()?;
Ok(Some(BudgetEngine {
budgets,
store,
prefix: if cfg.redis_prefix.trim().is_empty() {
"edgeguard".to_string()
} else {
cfg.redis_prefix.clone()
},
fail_open: cfg.fail_open,
}))
}
pub async fn reserve(&self, dims: Dims<'_>, estimate: Spend) -> Reserved {
let now = now_secs();
let mut held = Vec::new();
let mut observations = Vec::new();
for (idx, budget) in self.budgets.iter().enumerate() {
let amount = budget.amount(estimate.tokens, estimate.cost_micros);
if amount == 0 {
continue;
}
let key = budget.key(&self.prefix, &dims, now);
match self
.store
.reserve(&key, amount, budget.limit, budget.ttl_ms())
.await
{
Ok(outcome) if outcome.admitted => {
held.push((key, amount, idx));
observations.push(Observation {
name: budget.name.clone(),
consumed_ratio: ratio(outcome.used_after, budget.limit),
});
}
Ok(_) => {
let rollback_failures = self.rollback(&held).await;
return Reserved::Denied(Denial {
name: budget.name.clone(),
scope: budget.scope,
unit: budget.unit,
rollback_failures,
});
}
Err(e) => {
let rollback_failures = self.rollback(&held).await;
if self.fail_open {
warn!(error = %format!("{e:#}"), budget = %budget.name, "llm budget store error; failing open (allowing request)");
return Reserved::Ok(Reservation {
rollback_failures,
..Reservation::default()
});
}
warn!(error = %format!("{e:#}"), budget = %budget.name, "llm budget store error; failing closed (503)");
return Reserved::Error { rollback_failures };
}
}
}
Reserved::Ok(Reservation {
id: uuid::Uuid::new_v4().to_string(),
held,
observations,
rollback_failures: 0,
})
}
pub async fn reconcile(&self, reservation: &Reservation, actual: Spend) -> usize {
let mut failures = 0usize;
for (key, reserved, idx) in &reservation.held {
let budget = &self.budgets[*idx];
let actual_amount = budget.amount(actual.tokens, actual.cost_micros);
let delta = actual_amount as i64 - *reserved as i64;
if delta != 0 {
let marker = format!("{key}:s:{}", reservation.id);
if !self
.store
.reconcile(key, delta, budget.ttl_ms(), &marker)
.await
{
failures += 1;
}
}
}
failures
}
pub async fn release(&self, reservation: &Reservation) -> usize {
self.reconcile(reservation, Spend::default()).await
}
async fn rollback(&self, held: &[(String, u64, usize)]) -> usize {
let mut failures = 0usize;
for (key, amount, idx) in held {
let budget = &self.budgets[*idx];
let marker = format!("{key}:rb:{}", uuid::Uuid::new_v4());
if !self
.store
.reconcile(key, -(*amount as i64), budget.ttl_ms(), &marker)
.await
{
failures += 1;
}
}
failures
}
}
fn ratio(used: u64, limit: u64) -> f64 {
if limit == 0 {
return 0.0;
}
used as f64 / limit as f64
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::BudgetCfg;
fn token_budget(limit: f64, window: &str) -> BudgetCfg {
BudgetCfg {
name: "test".into(),
scope: "key".into(),
unit: "tokens".into(),
limit,
window: window.into(),
}
}
fn engine(budgets: Vec<BudgetCfg>) -> BudgetEngine {
BudgetEngine::build(&LlmCfg {
enabled: true,
budgets,
store: "memory".into(),
..Default::default()
})
.unwrap()
.expect("budgets configured")
}
fn dims<'a>(principal: Option<&'a str>, model: &'a str) -> Dims<'a> {
Dims {
principal,
model,
team: None,
}
}
#[test]
fn would_reserve_caps_at_limit() {
assert!(would_reserve(0, 100, 100));
assert!(would_reserve(90, 10, 100));
assert!(!would_reserve(90, 11, 100));
assert!(!would_reserve(u64::MAX, 1, 100));
}
#[test]
fn unit_and_scope_parse() {
assert_eq!(BudgetUnit::parse("tokens").unwrap(), BudgetUnit::Tokens);
assert_eq!(BudgetUnit::parse("USD").unwrap(), BudgetUnit::UsdMicros);
assert!(BudgetUnit::parse("bananas").is_err());
assert_eq!(BudgetScope::parse("global").unwrap(), BudgetScope::Global);
assert_eq!(BudgetScope::parse("per-key").unwrap(), BudgetScope::PerKey);
assert!(BudgetScope::parse("galaxy").is_err());
}
#[test]
fn build_is_none_without_budgets() {
let none = BudgetEngine::build(&LlmCfg::default()).unwrap();
assert!(none.is_none());
}
#[test]
fn limit_rounding_to_zero_is_rejected() {
assert!(Budget::build(&BudgetCfg {
name: "tiny".into(),
scope: "global".into(),
unit: "tokens".into(),
limit: 0.3,
window: "1h".into(),
})
.is_err());
}
#[test]
fn usd_budget_compiles_to_micros() {
let b = Budget::build(&BudgetCfg {
name: "spend".into(),
scope: "global".into(),
unit: "usd".into(),
limit: 2.50,
window: "24h".into(),
})
.unwrap();
assert_eq!(b.limit, 2_500_000); assert_eq!(b.unit, BudgetUnit::UsdMicros);
}
#[tokio::test]
async fn retry_async_succeeds_after_transient_failures() {
use std::sync::atomic::{AtomicU32, Ordering};
let calls = AtomicU32::new(0);
let r: Result<u32> = retry_async(3, Duration::from_millis(0), || {
let n = calls.fetch_add(1, Ordering::SeqCst);
async move {
if n < 2 {
anyhow::bail!("transient blip")
} else {
Ok(n)
}
}
})
.await;
assert_eq!(r.unwrap(), 2);
assert_eq!(calls.load(Ordering::SeqCst), 3);
}
#[tokio::test]
async fn retry_async_gives_up_after_exhausting_attempts() {
use std::sync::atomic::{AtomicU32, Ordering};
let calls = AtomicU32::new(0);
let r: Result<()> = retry_async(2, Duration::from_millis(0), || {
calls.fetch_add(1, Ordering::SeqCst);
async move { anyhow::bail!("always fails") }
})
.await;
assert!(r.is_err());
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[test]
fn memory_reconcile_is_idempotent_per_marker() {
let store = MemoryStore::default();
assert!(store.reserve("k", 100, 1000, 60_000).admitted); store.reconcile("k", -40, 60_000, "settle-1"); store.reconcile("k", -40, 60_000, "settle-1"); assert_eq!(store.reserve("k", 0, 1000, 60_000).used_after, 60);
store.reconcile("k", -10, 60_000, "settle-2");
assert_eq!(store.reserve("k", 0, 1000, 60_000).used_after, 50);
}
#[tokio::test]
async fn reserve_admits_until_limit_then_denies() {
let eng = engine(vec![token_budget(100.0, "1h")]);
let est = Spend {
tokens: 60,
cost_micros: 0,
};
assert!(matches!(
eng.reserve(dims(Some("alice"), "gpt-4o"), est).await,
Reserved::Ok(_)
));
assert!(matches!(
eng.reserve(dims(Some("alice"), "gpt-4o"), est).await,
Reserved::Denied(d) if d.name == "test"
));
assert!(matches!(
eng.reserve(dims(Some("bob"), "gpt-4o"), est).await,
Reserved::Ok(_)
));
}
#[tokio::test]
async fn reconcile_releases_overestimate() {
let eng = engine(vec![token_budget(100.0, "1h")]);
let reserved = match eng
.reserve(
dims(Some("c"), "m"),
Spend {
tokens: 80,
cost_micros: 0,
},
)
.await
{
Reserved::Ok(r) => r,
other => panic!("expected Ok, got {other:?}"),
};
eng.reconcile(
&reserved,
Spend {
tokens: 30,
cost_micros: 0,
},
)
.await;
assert!(matches!(
eng.reserve(
dims(Some("c"), "m"),
Spend {
tokens: 70,
cost_micros: 0
}
)
.await,
Reserved::Ok(_)
));
}
#[tokio::test]
async fn release_returns_full_reservation() {
let eng = engine(vec![token_budget(100.0, "1h")]);
let reserved = match eng
.reserve(
dims(Some("d"), "m"),
Spend {
tokens: 100,
cost_micros: 0,
},
)
.await
{
Reserved::Ok(r) => r,
other => panic!("expected Ok, got {other:?}"),
};
assert!(matches!(
eng.reserve(
dims(Some("d"), "m"),
Spend {
tokens: 1,
cost_micros: 0
}
)
.await,
Reserved::Denied(_)
));
eng.release(&reserved).await;
assert!(matches!(
eng.reserve(
dims(Some("d"), "m"),
Spend {
tokens: 100,
cost_micros: 0
}
)
.await,
Reserved::Ok(_)
));
}
#[tokio::test]
async fn multi_budget_denial_rolls_back_prior_reserve() {
let eng = engine(vec![
BudgetCfg {
name: "tok".into(),
scope: "global".into(),
unit: "tokens".into(),
limit: 1000.0,
window: "1h".into(),
},
BudgetCfg {
name: "cost".into(),
scope: "global".into(),
unit: "usd".into(),
limit: 0.000010, window: "1h".into(),
},
]);
assert!(matches!(
eng.reserve(dims(None, "m"), Spend { tokens: 100, cost_micros: 20 }).await,
Reserved::Denied(d) if d.name == "cost" && d.unit == BudgetUnit::UsdMicros
));
assert!(matches!(
eng.reserve(
dims(None, "m"),
Spend {
tokens: 1000,
cost_micros: 0
}
)
.await,
Reserved::Ok(_)
));
}
#[tokio::test]
async fn per_team_scope_is_keyed_by_team() {
let eng = engine(vec![BudgetCfg {
name: "team-cap".into(),
scope: "team".into(),
unit: "tokens".into(),
limit: 100.0,
window: "1h".into(),
}]);
let est = Spend {
tokens: 60,
cost_micros: 0,
};
let team_a = Dims {
principal: Some("alice"),
model: "gpt-4o",
team: Some("team-a"),
};
assert!(matches!(eng.reserve(team_a, est).await, Reserved::Ok(_)));
let team_a_bob = Dims {
principal: Some("bob"),
model: "gpt-4o",
team: Some("team-a"),
};
assert!(matches!(
eng.reserve(team_a_bob, est).await,
Reserved::Denied(d) if d.scope == BudgetScope::PerTeam
));
let team_b = Dims {
principal: Some("alice"),
model: "gpt-4o",
team: Some("team-b"),
};
assert!(matches!(eng.reserve(team_b, est).await, Reserved::Ok(_)));
}
#[tokio::test]
async fn reserve_reports_consumed_ratio() {
let eng = engine(vec![token_budget(100.0, "1h")]);
let r = match eng
.reserve(
dims(Some("alice"), "m"),
Spend {
tokens: 75,
cost_micros: 0,
},
)
.await
{
Reserved::Ok(r) => r,
other => panic!("expected Ok, got {other:?}"),
};
let obs = r.observations();
assert_eq!(obs.len(), 1);
assert_eq!(obs[0].name, "test");
assert!(
(obs[0].consumed_ratio - 0.75).abs() < 1e-9,
"{}",
obs[0].consumed_ratio
);
}
fn redis_url() -> String {
std::env::var("EDGEGUARD_TEST_REDIS_URL")
.unwrap_or_else(|_| "redis://127.0.0.1:6379".into())
}
#[tokio::test]
#[ignore = "requires a live Redis (EDGEGUARD_TEST_REDIS_URL, default redis://127.0.0.1:6379)"]
async fn redis_budget_reserve_and_reconcile_live() {
let eng = BudgetEngine::build(&LlmCfg {
enabled: true,
store: "redis".into(),
redis_url: redis_url(),
redis_prefix: format!("egtest:budget:{}:{}", std::process::id(), now_secs()),
budgets: vec![token_budget(100.0, "1h")],
..Default::default()
})
.unwrap()
.expect("budgets configured");
let est = Spend {
tokens: 60,
cost_micros: 0,
};
let reserved = match eng.reserve(dims(Some("alice"), "m"), est).await {
Reserved::Ok(r) => r,
Reserved::Error { .. } => {
eprintln!("skipping redis_budget_reserve_and_reconcile_live: Redis unreachable");
return;
}
other => panic!("unexpected first reserve: {other:?}"),
};
assert!(matches!(
eng.reserve(dims(Some("alice"), "m"), est).await,
Reserved::Denied(_)
));
eng.reconcile(
&reserved,
Spend {
tokens: 10,
cost_micros: 0,
},
)
.await;
assert!(matches!(
eng.reserve(dims(Some("alice"), "m"), est).await,
Reserved::Ok(_)
));
}
}