Files
Aether/crates/aether-cache/src/ttl_map.rs
fawney19 fa328e18a1 feat: provider api_formats 可空继承、OpenAI 图片 edit/variation 与用量配额多项补强
- 鉴权: provider_api_keys.api_formats 改为可空,OAuth 托管 key 自动继承 provider endpoints 激活格式,相关 handler/测试同步更新
- 图片 planner: OpenAI 图片路由新增 edit/variation 操作并完善参数校验、响应合并与流式处理
- 用量: user me usage 返回区分 client_requested_stream/upstream_is_stream,前端 usage 列表筛选与展示增强
- 统计: stats_daily_model 新增 cache_creation_ephemeral_5m/1h tokens 字段与回填链路
- 配额/observability: quota repository 新增内存与 SQL 扩展,admin observability usage 字段扩充
- 其它: OAuth 导入/轮询收敛、provider 汇总与 pool admin 读写链路小修、新增 system_config 缓存与 provider template handler

Closes #318

Co-authored-by: Entropy.Xu <53283266+Entropy-Xu@users.noreply.github.com>
2026-04-23 14:42:51 +08:00

208 lines
4.8 KiB
Rust

use std::collections::HashMap;
use std::hash::Hash;
use std::sync::Mutex;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
struct TimedEntry<V> {
value: V,
inserted_at: Instant,
}
#[derive(Debug)]
pub struct ExpiringMap<K, V> {
entries: Mutex<HashMap<K, TimedEntry<V>>>,
}
#[derive(Debug, Clone)]
pub struct ExpiringMapFreshEntry<K, V> {
pub key: K,
pub value: V,
pub age: Duration,
}
impl<K, V> Default for ExpiringMap<K, V> {
fn default() -> Self {
Self {
entries: Mutex::new(HashMap::new()),
}
}
}
impl<K, V> ExpiringMap<K, V>
where
K: Eq + Hash + Clone,
{
pub fn new() -> Self {
Self::default()
}
pub fn insert(&self, key: K, value: V, ttl: Duration, max_entries: usize) {
let Ok(mut entries) = self.entries.lock() else {
return;
};
prune_expired(&mut entries, ttl);
while max_entries > 0 && entries.len() >= max_entries {
let Some(oldest_key) = entries
.iter()
.min_by_key(|(_, entry)| entry.inserted_at)
.map(|(key, _)| key.clone())
else {
break;
};
entries.remove(&oldest_key);
}
entries.insert(
key,
TimedEntry {
value,
inserted_at: Instant::now(),
},
);
}
pub fn remove(&self, key: &K) -> Option<V> {
let Ok(mut entries) = self.entries.lock() else {
return None;
};
entries.remove(key).map(|entry| entry.value)
}
pub fn len(&self) -> usize {
self.entries
.lock()
.map(|entries| entries.len())
.unwrap_or(0)
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn clear(&self) {
let Ok(mut entries) = self.entries.lock() else {
return;
};
entries.clear();
}
}
impl<K, V> ExpiringMap<K, V>
where
K: Eq + Hash + Clone,
V: Clone,
{
pub fn get_fresh(&self, key: &K, ttl: Duration) -> Option<V> {
let Ok(mut entries) = self.entries.lock() else {
return None;
};
let entry = entries.get(key).cloned()?;
if entry.inserted_at.elapsed() > ttl {
entries.remove(key);
return None;
}
Some(entry.value)
}
pub fn contains_fresh(&self, key: &K, ttl: Duration) -> bool {
self.get_fresh(key, ttl).is_some()
}
pub fn snapshot_fresh(&self, ttl: Duration) -> Vec<ExpiringMapFreshEntry<K, V>> {
let Ok(mut entries) = self.entries.lock() else {
return Vec::new();
};
prune_expired(&mut entries, ttl);
entries
.iter()
.map(|(key, entry)| ExpiringMapFreshEntry {
key: key.clone(),
value: entry.value.clone(),
age: entry.inserted_at.elapsed(),
})
.collect()
}
}
fn prune_expired<K, V>(entries: &mut HashMap<K, TimedEntry<V>>, ttl: Duration)
where
K: Eq + Hash,
{
if ttl.is_zero() {
entries.clear();
return;
}
entries.retain(|_, entry| entry.inserted_at.elapsed() <= ttl);
}
#[cfg(test)]
mod tests {
use std::thread::sleep;
use super::ExpiringMap;
#[test]
fn evicts_expired_entries_on_read() {
let cache = ExpiringMap::new();
cache.insert(
"hello".to_string(),
42_u32,
std::time::Duration::from_millis(10),
16,
);
sleep(std::time::Duration::from_millis(20));
assert_eq!(
cache.get_fresh(&"hello".to_string(), std::time::Duration::from_millis(10)),
None
);
assert_eq!(cache.len(), 0);
}
#[test]
fn evicts_oldest_entry_when_capacity_is_hit() {
let cache = ExpiringMap::new();
cache.insert(
"one".to_string(),
1_u32,
std::time::Duration::from_secs(60),
2,
);
sleep(std::time::Duration::from_millis(2));
cache.insert(
"two".to_string(),
2_u32,
std::time::Duration::from_secs(60),
2,
);
sleep(std::time::Duration::from_millis(2));
cache.insert(
"three".to_string(),
3_u32,
std::time::Duration::from_secs(60),
2,
);
assert_eq!(
cache.get_fresh(&"one".to_string(), std::time::Duration::from_secs(60)),
None
);
assert_eq!(
cache.get_fresh(&"two".to_string(), std::time::Duration::from_secs(60)),
Some(2)
);
assert_eq!(
cache.get_fresh(&"three".to_string(), std::time::Duration::from_secs(60)),
Some(3)
);
}
}