mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 22:50:19 +08:00
perf: reduce gateway db pressure under load
This commit is contained in:
@@ -10,9 +10,7 @@ impl<'a> PlannerAppState<'a> {
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<GatewayAuthApiKeySnapshot>, GatewayError> {
|
||||
self.app()
|
||||
.data
|
||||
.read_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs)
|
||||
.read_cached_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
+522
@@ -0,0 +1,522 @@
|
||||
use std::collections::HashSet;
|
||||
use std::future::Future;
|
||||
use std::hash::Hash;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_cache::ExpiringMap;
|
||||
use serde_json::Value;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||
|
||||
const AUTH_RUNTIME_CACHE_MAX_ENTRIES: usize = 16_384;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CacheSingleflight<K> {
|
||||
inflight: StdMutex<HashSet<K>>,
|
||||
notify: Notify,
|
||||
}
|
||||
|
||||
impl<K> Default for CacheSingleflight<K> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
inflight: StdMutex::new(HashSet::new()),
|
||||
notify: Notify::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum CacheInflightRegistration<'a, K: Eq + Hash> {
|
||||
Leader(CacheInflightGuard<'a, K>),
|
||||
Follower,
|
||||
Bypass,
|
||||
}
|
||||
|
||||
struct CacheInflightGuard<'a, K: Eq + Hash> {
|
||||
singleflight: &'a CacheSingleflight<K>,
|
||||
key: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> CacheSingleflight<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn notified(&self) -> tokio::sync::futures::Notified<'_> {
|
||||
self.notify.notified()
|
||||
}
|
||||
|
||||
fn finish(&self, key: &K) {
|
||||
let removed = self
|
||||
.inflight
|
||||
.lock()
|
||||
.map(|mut inflight| inflight.remove(key))
|
||||
.unwrap_or(false);
|
||||
if removed {
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
|
||||
fn clear(&self) {
|
||||
let cleared = self
|
||||
.inflight
|
||||
.lock()
|
||||
.map(|mut inflight| {
|
||||
let had_entries = !inflight.is_empty();
|
||||
inflight.clear();
|
||||
had_entries
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if cleared {
|
||||
self.notify.notify_waiters();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> CacheSingleflight<K>
|
||||
where
|
||||
K: Clone + Eq + Hash,
|
||||
{
|
||||
fn register(&self, key: &K) -> CacheInflightRegistration<'_, K> {
|
||||
match self.inflight.lock() {
|
||||
Ok(mut inflight) => {
|
||||
if inflight.contains(key) {
|
||||
CacheInflightRegistration::Follower
|
||||
} else {
|
||||
inflight.insert(key.clone());
|
||||
CacheInflightRegistration::Leader(CacheInflightGuard {
|
||||
singleflight: self,
|
||||
key: Some(key.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
Err(_) => CacheInflightRegistration::Bypass,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> Drop for CacheInflightGuard<'_, K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn drop(&mut self) {
|
||||
if let Some(key) = self.key.take() {
|
||||
self.singleflight.finish(&key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct AuthApiKeyIdentityCacheKey {
|
||||
user_id: String,
|
||||
api_key_id: String,
|
||||
}
|
||||
|
||||
impl AuthApiKeyIdentityCacheKey {
|
||||
pub(crate) fn new(user_id: &str, api_key_id: &str) -> Self {
|
||||
Self {
|
||||
user_id: user_id.trim().to_string(),
|
||||
api_key_id: api_key_id.trim().to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.user_id.is_empty() || self.api_key_id.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct AuthApiKeyFeatureCacheKey {
|
||||
user_id: String,
|
||||
api_key_id: String,
|
||||
is_standalone: bool,
|
||||
}
|
||||
|
||||
impl AuthApiKeyFeatureCacheKey {
|
||||
pub(crate) fn new(user_id: &str, api_key_id: &str, is_standalone: bool) -> Self {
|
||||
Self {
|
||||
user_id: user_id.trim().to_string(),
|
||||
api_key_id: api_key_id.trim().to_string(),
|
||||
is_standalone,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
self.api_key_id.is_empty() || (!self.is_standalone && self.user_id.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct AuthSnapshotCache {
|
||||
entries: ExpiringMap<AuthSnapshotCacheKey, Option<GatewayAuthApiKeySnapshot>>,
|
||||
singleflight: CacheSingleflight<AuthSnapshotCacheKey>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) enum AuthSnapshotCacheKey {
|
||||
KeyHash(String),
|
||||
UserApiKeyIds(AuthApiKeyIdentityCacheKey),
|
||||
}
|
||||
|
||||
impl AuthSnapshotCacheKey {
|
||||
pub(crate) fn key_hash(key_hash: &str) -> Self {
|
||||
Self::KeyHash(key_hash.trim().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn user_api_key_ids(user_id: &str, api_key_id: &str) -> Self {
|
||||
Self::UserApiKeyIds(AuthApiKeyIdentityCacheKey::new(user_id, api_key_id))
|
||||
}
|
||||
|
||||
pub(crate) fn is_empty(&self) -> bool {
|
||||
match self {
|
||||
Self::KeyHash(key_hash) => key_hash.is_empty(),
|
||||
Self::UserApiKeyIds(key) => key.is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthSnapshotCache {
|
||||
pub(crate) fn get(
|
||||
&self,
|
||||
key: &AuthSnapshotCacheKey,
|
||||
ttl: Duration,
|
||||
) -> Option<Option<GatewayAuthApiKeySnapshot>> {
|
||||
self.entries.get_fresh(key, ttl)
|
||||
}
|
||||
|
||||
pub(crate) fn insert(
|
||||
&self,
|
||||
key: AuthSnapshotCacheKey,
|
||||
value: Option<GatewayAuthApiKeySnapshot>,
|
||||
ttl: Duration,
|
||||
) {
|
||||
self.entries
|
||||
.insert(key, value, ttl, AUTH_RUNTIME_CACHE_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
pub(crate) async fn get_or_load<E, F, Fut>(
|
||||
&self,
|
||||
key: AuthSnapshotCacheKey,
|
||||
ttl: Duration,
|
||||
load: F,
|
||||
) -> Result<Option<GatewayAuthApiKeySnapshot>, E>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<Option<GatewayAuthApiKeySnapshot>, E>>,
|
||||
{
|
||||
if let Some(value) = self.get(&key, ttl) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
loop {
|
||||
let notified = self.singleflight.notified();
|
||||
match self.singleflight.register(&key) {
|
||||
CacheInflightRegistration::Bypass => {
|
||||
let value = load().await?;
|
||||
self.insert(key, value.clone(), ttl);
|
||||
return Ok(value);
|
||||
}
|
||||
CacheInflightRegistration::Follower => {
|
||||
notified.await;
|
||||
if let Some(value) = self.get(&key, ttl) {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
CacheInflightRegistration::Leader(_guard) => {
|
||||
let value = load().await?;
|
||||
self.insert(key, value.clone(), ttl);
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
self.entries.clear();
|
||||
self.singleflight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct JsonValueCache<K> {
|
||||
entries: ExpiringMap<K, Option<Value>>,
|
||||
singleflight: CacheSingleflight<K>,
|
||||
}
|
||||
|
||||
impl<K> Default for JsonValueCache<K> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: ExpiringMap::default(),
|
||||
singleflight: CacheSingleflight::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> JsonValueCache<K>
|
||||
where
|
||||
K: Clone + Eq + Hash,
|
||||
{
|
||||
pub(crate) fn get(&self, key: &K, ttl: Duration) -> Option<Option<Value>> {
|
||||
self.entries.get_fresh(key, ttl)
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&self, key: K, value: Option<Value>, ttl: Duration) {
|
||||
self.entries
|
||||
.insert(key, value, ttl, AUTH_RUNTIME_CACHE_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
pub(crate) async fn get_or_load<E, F, Fut>(
|
||||
&self,
|
||||
key: K,
|
||||
ttl: Duration,
|
||||
load: F,
|
||||
) -> Result<Option<Value>, E>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<Option<Value>, E>>,
|
||||
{
|
||||
if let Some(value) = self.get(&key, ttl) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
loop {
|
||||
let notified = self.singleflight.notified();
|
||||
match self.singleflight.register(&key) {
|
||||
CacheInflightRegistration::Bypass => {
|
||||
let value = load().await?;
|
||||
self.insert(key, value.clone(), ttl);
|
||||
return Ok(value);
|
||||
}
|
||||
CacheInflightRegistration::Follower => {
|
||||
notified.await;
|
||||
if let Some(value) = self.get(&key, ttl) {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
CacheInflightRegistration::Leader(_guard) => {
|
||||
let value = load().await?;
|
||||
self.insert(key, value.clone(), ttl);
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
self.entries.clear();
|
||||
self.singleflight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ValueCache<K, V> {
|
||||
entries: ExpiringMap<K, Option<V>>,
|
||||
singleflight: CacheSingleflight<K>,
|
||||
}
|
||||
|
||||
impl<K, V> Default for ValueCache<K, V> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: ExpiringMap::default(),
|
||||
singleflight: CacheSingleflight::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K, V> ValueCache<K, V>
|
||||
where
|
||||
K: Clone + Eq + Hash,
|
||||
V: Clone,
|
||||
{
|
||||
pub(crate) fn get(&self, key: &K, ttl: Duration) -> Option<Option<V>> {
|
||||
self.entries.get_fresh(key, ttl)
|
||||
}
|
||||
|
||||
pub(crate) fn insert(&self, key: K, value: Option<V>, ttl: Duration) {
|
||||
self.entries
|
||||
.insert(key, value, ttl, AUTH_RUNTIME_CACHE_MAX_ENTRIES);
|
||||
}
|
||||
|
||||
pub(crate) async fn get_or_load<E, F, Fut>(
|
||||
&self,
|
||||
key: K,
|
||||
ttl: Duration,
|
||||
load: F,
|
||||
) -> Result<Option<V>, E>
|
||||
where
|
||||
F: Fn() -> Fut,
|
||||
Fut: Future<Output = Result<Option<V>, E>>,
|
||||
{
|
||||
if let Some(value) = self.get(&key, ttl) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
loop {
|
||||
let notified = self.singleflight.notified();
|
||||
match self.singleflight.register(&key) {
|
||||
CacheInflightRegistration::Bypass => {
|
||||
let value = load().await?;
|
||||
self.insert(key, value.clone(), ttl);
|
||||
return Ok(value);
|
||||
}
|
||||
CacheInflightRegistration::Follower => {
|
||||
notified.await;
|
||||
if let Some(value) = self.get(&key, ttl) {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
CacheInflightRegistration::Leader(_guard) => {
|
||||
let value = load().await?;
|
||||
self.insert(key, value.clone(), ttl);
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
self.entries.clear();
|
||||
self.singleflight.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::ValueCache;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn record_max(max_seen: &AtomicUsize, value: usize) {
|
||||
let mut current = max_seen.load(Ordering::Acquire);
|
||||
while value > current {
|
||||
match max_seen.compare_exchange(current, value, Ordering::AcqRel, Ordering::Acquire) {
|
||||
Ok(_) => break,
|
||||
Err(next) => current = next,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_cache_coalesces_concurrent_loads_for_same_key() {
|
||||
let cache = Arc::new(ValueCache::<String, u64>::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for _ in 0..16 {
|
||||
let cache = Arc::clone(&cache);
|
||||
let calls = Arc::clone(&calls);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
cache
|
||||
.get_or_load::<(), _, _>(
|
||||
"same-key".to_string(),
|
||||
Duration::from_secs(60),
|
||||
|| async {
|
||||
calls.fetch_add(1, Ordering::AcqRel);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
Ok(Some(42))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
assert_eq!(task.await.unwrap(), Some(42));
|
||||
}
|
||||
assert_eq!(calls.load(Ordering::Acquire), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_cache_loads_different_keys_without_global_blocking() {
|
||||
let cache = Arc::new(ValueCache::<String, u64>::default());
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let active = Arc::new(AtomicUsize::new(0));
|
||||
let max_active = Arc::new(AtomicUsize::new(0));
|
||||
let started = Instant::now();
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for (key, value) in [("key-a", 1_u64), ("key-b", 2_u64)] {
|
||||
let cache = Arc::clone(&cache);
|
||||
let calls = Arc::clone(&calls);
|
||||
let active = Arc::clone(&active);
|
||||
let max_active = Arc::clone(&max_active);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
cache
|
||||
.get_or_load::<(), _, _>(key.to_string(), Duration::from_secs(60), || {
|
||||
let calls = Arc::clone(&calls);
|
||||
let active = Arc::clone(&active);
|
||||
let max_active = Arc::clone(&max_active);
|
||||
async move {
|
||||
calls.fetch_add(1, Ordering::AcqRel);
|
||||
let current = active.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
record_max(&max_active, current);
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
active.fetch_sub(1, Ordering::AcqRel);
|
||||
Ok(Some(value))
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
for task in tasks {
|
||||
results.push(task.await.unwrap());
|
||||
}
|
||||
results.sort_unstable();
|
||||
|
||||
assert_eq!(results, vec![Some(1), Some(2)]);
|
||||
assert_eq!(calls.load(Ordering::Acquire), 2);
|
||||
assert_eq!(max_active.load(Ordering::Acquire), 2);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_millis(95),
|
||||
"different cache keys should load in parallel, elapsed={:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn value_cache_clear_releases_same_key_followers() {
|
||||
let cache = Arc::new(ValueCache::<String, u64>::default());
|
||||
let leader_cache = Arc::clone(&cache);
|
||||
let leader = tokio::spawn(async move {
|
||||
leader_cache
|
||||
.get_or_load::<(), _, _>(
|
||||
"stuck-key".to_string(),
|
||||
Duration::from_secs(60),
|
||||
|| async {
|
||||
std::future::pending::<()>().await;
|
||||
Ok(Some(1))
|
||||
},
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
let follower_cache = Arc::clone(&cache);
|
||||
let follower = tokio::spawn(async move {
|
||||
follower_cache
|
||||
.get_or_load::<(), _, _>(
|
||||
"stuck-key".to_string(),
|
||||
Duration::from_secs(60),
|
||||
|| async { Ok(Some(2)) },
|
||||
)
|
||||
.await
|
||||
});
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
|
||||
cache.clear();
|
||||
let value = tokio::time::timeout(Duration::from_millis(200), follower)
|
||||
.await
|
||||
.expect("clear should wake followers")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(value, Some(2));
|
||||
|
||||
leader.abort();
|
||||
let _ = leader.await;
|
||||
}
|
||||
}
|
||||
Vendored
+5
@@ -1,5 +1,6 @@
|
||||
mod auth_api_key_last_used;
|
||||
mod auth_context;
|
||||
mod auth_runtime;
|
||||
mod dashboard_response;
|
||||
mod direct_plan_bypass;
|
||||
mod scheduler_affinity;
|
||||
@@ -7,6 +8,10 @@ mod system_config;
|
||||
|
||||
pub(crate) use auth_api_key_last_used::AuthApiKeyLastUsedCache;
|
||||
pub(crate) use auth_context::AuthContextCache;
|
||||
pub(crate) use auth_runtime::{
|
||||
AuthApiKeyFeatureCacheKey, AuthApiKeyIdentityCacheKey, AuthSnapshotCache, AuthSnapshotCacheKey,
|
||||
JsonValueCache, ValueCache,
|
||||
};
|
||||
pub(crate) use dashboard_response::DashboardResponseCache;
|
||||
pub(crate) use direct_plan_bypass::DirectPlanBypassCache;
|
||||
pub(crate) use scheduler_affinity::{
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_cache::ExpiringMap;
|
||||
use tokio::sync::{Mutex, MutexGuard};
|
||||
|
||||
const MAX_ENTRIES: usize = 512;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct SystemConfigCache {
|
||||
entries: ExpiringMap<String, Option<serde_json::Value>>,
|
||||
load_guard: Mutex<()>,
|
||||
}
|
||||
|
||||
impl Default for SystemConfigCache {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: ExpiringMap::new(),
|
||||
load_guard: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +29,10 @@ impl SystemConfigCache {
|
||||
self.entries.insert(key, value, ttl, MAX_ENTRIES);
|
||||
}
|
||||
|
||||
pub(crate) async fn load_guard(&self) -> MutexGuard<'_, ()> {
|
||||
self.load_guard.lock().await
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ pub(super) fn build_auth_context_cache_key(
|
||||
}
|
||||
|
||||
let extracted = extract_request_credentials(headers, uri, signature);
|
||||
let trusted_headers = extracted.trusted_headers;
|
||||
let bundle = extracted.bundle;
|
||||
if bundle.authorization_bearer.is_none()
|
||||
&& bundle.x_api_key.is_none()
|
||||
@@ -97,18 +98,41 @@ pub(super) fn build_auth_context_cache_key(
|
||||
&& bundle.x_goog_api_key.is_none()
|
||||
&& bundle.query_key.is_none()
|
||||
&& bundle.cookie_header.is_none()
|
||||
&& trusted_headers.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let (trusted_user_id, trusted_api_key_id, trusted_balance_remaining, trusted_access_allowed) =
|
||||
trusted_headers
|
||||
.map(|trusted| {
|
||||
(
|
||||
trusted.user_id,
|
||||
trusted.api_key_id,
|
||||
trusted
|
||||
.balance_remaining
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
trusted
|
||||
.access_allowed
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(format!(
|
||||
"{signature}\n{}\n{}\n{}\n{}\n{}\n{}",
|
||||
"{signature}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}\n{}",
|
||||
bundle.authorization_bearer.unwrap_or_default(),
|
||||
bundle.x_api_key.unwrap_or_default(),
|
||||
bundle.api_key.unwrap_or_default(),
|
||||
bundle.x_goog_api_key.unwrap_or_default(),
|
||||
bundle.query_key.unwrap_or_default(),
|
||||
bundle.cookie_header.unwrap_or_default(),
|
||||
trusted_user_id,
|
||||
trusted_api_key_id,
|
||||
trusted_balance_remaining,
|
||||
trusted_access_allowed,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -551,6 +575,103 @@ mod tests {
|
||||
assert!(cache_key.contains("session=abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_includes_trusted_auth_headers() {
|
||||
let mut first_headers = http::HeaderMap::new();
|
||||
first_headers.insert(
|
||||
crate::constants::GATEWAY_HEADER,
|
||||
"rust-phase3b".parse().unwrap(),
|
||||
);
|
||||
first_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_USER_ID_HEADER,
|
||||
"user-1".parse().unwrap(),
|
||||
);
|
||||
first_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_API_KEY_ID_HEADER,
|
||||
"key-1".parse().unwrap(),
|
||||
);
|
||||
first_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_BALANCE_HEADER,
|
||||
"1.5".parse().unwrap(),
|
||||
);
|
||||
first_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_ACCESS_ALLOWED_HEADER,
|
||||
"true".parse().unwrap(),
|
||||
);
|
||||
|
||||
let mut second_headers = first_headers.clone();
|
||||
second_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_USER_ID_HEADER,
|
||||
"user-2".parse().unwrap(),
|
||||
);
|
||||
second_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_ACCESS_ALLOWED_HEADER,
|
||||
"false".parse().unwrap(),
|
||||
);
|
||||
|
||||
let first = build_auth_context_cache_key(
|
||||
&first_headers,
|
||||
&uri("/v1/chat/completions"),
|
||||
"openai:chat",
|
||||
)
|
||||
.expect("trusted cache key should exist");
|
||||
let second = build_auth_context_cache_key(
|
||||
&second_headers,
|
||||
&uri("/v1/chat/completions"),
|
||||
"openai:chat",
|
||||
)
|
||||
.expect("trusted cache key should exist");
|
||||
|
||||
assert_ne!(first, second);
|
||||
assert!(first.contains("user-1"));
|
||||
assert!(first.contains("key-1"));
|
||||
assert!(first.contains("1.5"));
|
||||
assert!(first.contains("true"));
|
||||
assert!(second.contains("user-2"));
|
||||
assert!(second.contains("false"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_key_ignores_untrusted_auth_identity_headers() {
|
||||
let mut trusted_headers = http::HeaderMap::new();
|
||||
trusted_headers.insert(
|
||||
crate::constants::GATEWAY_HEADER,
|
||||
"rust-phase3b".parse().unwrap(),
|
||||
);
|
||||
trusted_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_USER_ID_HEADER,
|
||||
"user-1".parse().unwrap(),
|
||||
);
|
||||
trusted_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_API_KEY_ID_HEADER,
|
||||
"key-1".parse().unwrap(),
|
||||
);
|
||||
|
||||
let mut untrusted_headers = http::HeaderMap::new();
|
||||
untrusted_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_USER_ID_HEADER,
|
||||
"user-1".parse().unwrap(),
|
||||
);
|
||||
untrusted_headers.insert(
|
||||
crate::constants::TRUSTED_AUTH_API_KEY_ID_HEADER,
|
||||
"key-1".parse().unwrap(),
|
||||
);
|
||||
|
||||
let trusted = build_auth_context_cache_key(
|
||||
&trusted_headers,
|
||||
&uri("/v1/chat/completions"),
|
||||
"openai:chat",
|
||||
);
|
||||
let untrusted = build_auth_context_cache_key(
|
||||
&untrusted_headers,
|
||||
&uri("/v1/chat/completions"),
|
||||
"openai:chat",
|
||||
);
|
||||
|
||||
assert!(trusted.is_some());
|
||||
assert_eq!(untrusted, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_trusted_auth_headers() {
|
||||
let mut headers = http::HeaderMap::new();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{sync::OnceLock, time::Duration};
|
||||
|
||||
use aether_data_contracts::repository::provider_catalog::{
|
||||
StoredProviderCatalogEndpoint, StoredProviderCatalogProvider,
|
||||
@@ -26,7 +26,14 @@ use super::types::{
|
||||
use crate::headers::header_value_str;
|
||||
|
||||
const AUTH_CONTEXT_CACHE_TTL: Duration = Duration::from_secs(60);
|
||||
const AUTH_CONTEXT_CACHE_MAX_ENTRIES: usize = 256;
|
||||
const AUTH_CONTEXT_NEGATIVE_CACHE_TTL: Duration = Duration::from_secs(10);
|
||||
const AUTH_CONTEXT_CACHE_MAX_ENTRIES: usize = 10_000;
|
||||
const AUTH_CONTEXT_CACHE_MAX_ENTRIES_ENV: &str = "AETHER_GATEWAY_AUTH_CONTEXT_CACHE_MAX_ENTRIES";
|
||||
const AUTH_CONTEXT_CACHE_REFRESH_ON_HIT_ENV: &str =
|
||||
"AETHER_GATEWAY_AUTH_CONTEXT_CACHE_REFRESH_ON_HIT";
|
||||
const AUTH_CONTEXT_NEGATIVE_CACHE_TTL_SECS_ENV: &str =
|
||||
"AETHER_GATEWAY_AUTH_CONTEXT_NEGATIVE_CACHE_TTL_SECS";
|
||||
const AUTH_CONTEXT_NEGATIVE_CACHE_KEY_PREFIX: &str = "negative:";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct AntigravityBearerBridgeConfig {
|
||||
@@ -121,28 +128,49 @@ pub(in super::super) async fn resolve_control_decision_auth(
|
||||
decision.admin_principal = Some(admin_principal);
|
||||
}
|
||||
|
||||
if let Some(auth_context) = resolve_data_backed_auth_context(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
log_auth_context_resolution(trace_id, &decision, &auth_context);
|
||||
decision.local_auth_rejection = auth_context.local_rejection.clone();
|
||||
if !auth_context.user_id.is_empty() && !auth_context.api_key_id.is_empty() {
|
||||
if let Some(cache_key) = decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.and_then(|signature| build_auth_context_cache_key(headers, uri, signature))
|
||||
{
|
||||
put_cached_auth_context(state, cache_key, auth_context.clone());
|
||||
}
|
||||
decision.auth_context = Some(auth_context);
|
||||
let auth_context_cache_key = decision
|
||||
.auth_endpoint_signature
|
||||
.as_deref()
|
||||
.and_then(|signature| build_auth_context_cache_key(headers, uri, signature));
|
||||
|
||||
let mut resolved_auth_context = None;
|
||||
if let Some(cache_key) = auth_context_cache_key.as_deref() {
|
||||
if let Some(auth_context) = get_cached_auth_context(state, cache_key) {
|
||||
resolved_auth_context = if auth_context_cache_refresh_on_hit() {
|
||||
let refreshed = refresh_execution_runtime_auth_context(
|
||||
state,
|
||||
auth_context,
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
put_cached_auth_context(state, cache_key.to_string(), refreshed.clone());
|
||||
Some(refreshed)
|
||||
} else {
|
||||
Some(auth_context)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if resolved_auth_context.is_none() {
|
||||
resolved_auth_context = resolve_data_backed_auth_context(
|
||||
state,
|
||||
headers,
|
||||
uri,
|
||||
decision.auth_endpoint_signature.as_deref(),
|
||||
)
|
||||
.await?;
|
||||
if let (Some(cache_key), Some(auth_context)) = (
|
||||
auth_context_cache_key.as_ref(),
|
||||
resolved_auth_context.as_ref(),
|
||||
) {
|
||||
put_cached_auth_context(state, cache_key.clone(), auth_context.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(auth_context) = resolved_auth_context {
|
||||
apply_resolved_auth_context_to_decision(trace_id, &mut decision, auth_context);
|
||||
}
|
||||
|
||||
if decision.local_auth_rejection.is_some() {
|
||||
log_local_auth_rejection(trace_id, &decision);
|
||||
return Ok(ControlDecisionAuthResolution::Resolved(decision));
|
||||
@@ -470,6 +498,9 @@ pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||
let _ = trace_id;
|
||||
|
||||
if let Some(auth_context) = decision.auth_context.clone() {
|
||||
if !auth_context_cache_refresh_on_hit() {
|
||||
return Ok(Some(auth_context));
|
||||
}
|
||||
return Ok(Some(
|
||||
refresh_execution_runtime_auth_context(
|
||||
state,
|
||||
@@ -489,6 +520,10 @@ pub(crate) async fn resolve_execution_runtime_auth_context(
|
||||
};
|
||||
|
||||
if let Some(auth_context) = get_cached_auth_context(state, &cache_key) {
|
||||
if !auth_context_cache_refresh_on_hit() {
|
||||
return Ok(Some(auth_context));
|
||||
}
|
||||
|
||||
let refreshed = refresh_execution_runtime_auth_context(
|
||||
state,
|
||||
auth_context,
|
||||
@@ -567,14 +602,88 @@ fn put_cached_auth_context(
|
||||
cache_key: String,
|
||||
auth_context: GatewayControlAuthContext,
|
||||
) {
|
||||
let (cache_key, ttl) = if is_negative_auth_context(&auth_context) {
|
||||
let ttl = auth_context_negative_cache_ttl();
|
||||
if ttl.is_zero() {
|
||||
return;
|
||||
}
|
||||
(
|
||||
negative_auth_context_cache_key(&cache_key),
|
||||
AUTH_CONTEXT_CACHE_TTL.max(ttl),
|
||||
)
|
||||
} else {
|
||||
(cache_key, AUTH_CONTEXT_CACHE_TTL)
|
||||
};
|
||||
state.auth_context_cache.insert(
|
||||
cache_key,
|
||||
auth_context,
|
||||
AUTH_CONTEXT_CACHE_TTL,
|
||||
AUTH_CONTEXT_CACHE_MAX_ENTRIES,
|
||||
ttl,
|
||||
auth_context_cache_max_entries(),
|
||||
);
|
||||
}
|
||||
|
||||
fn auth_context_cache_max_entries() -> usize {
|
||||
static MAX_ENTRIES: OnceLock<usize> = OnceLock::new();
|
||||
*MAX_ENTRIES.get_or_init(|| {
|
||||
std::env::var(AUTH_CONTEXT_CACHE_MAX_ENTRIES_ENV)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(AUTH_CONTEXT_CACHE_MAX_ENTRIES)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_context_cache_refresh_on_hit() -> bool {
|
||||
static REFRESH_ON_HIT: OnceLock<bool> = OnceLock::new();
|
||||
*REFRESH_ON_HIT.get_or_init(|| {
|
||||
std::env::var(AUTH_CONTEXT_CACHE_REFRESH_ON_HIT_ENV)
|
||||
.ok()
|
||||
.map(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
.unwrap_or(true)
|
||||
})
|
||||
}
|
||||
|
||||
fn auth_context_negative_cache_ttl() -> Duration {
|
||||
static NEGATIVE_TTL: OnceLock<Duration> = OnceLock::new();
|
||||
*NEGATIVE_TTL.get_or_init(|| {
|
||||
std::env::var(AUTH_CONTEXT_NEGATIVE_CACHE_TTL_SECS_ENV)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(AUTH_CONTEXT_NEGATIVE_CACHE_TTL)
|
||||
})
|
||||
}
|
||||
|
||||
fn negative_auth_context_cache_key(cache_key: &str) -> String {
|
||||
format!("{AUTH_CONTEXT_NEGATIVE_CACHE_KEY_PREFIX}{cache_key}")
|
||||
}
|
||||
|
||||
fn is_negative_auth_context(auth_context: &GatewayControlAuthContext) -> bool {
|
||||
auth_context.user_id.is_empty()
|
||||
|| auth_context.api_key_id.is_empty()
|
||||
|| matches!(
|
||||
auth_context.local_rejection,
|
||||
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
||||
)
|
||||
}
|
||||
|
||||
fn apply_resolved_auth_context_to_decision(
|
||||
trace_id: &str,
|
||||
decision: &mut GatewayControlDecision,
|
||||
auth_context: GatewayControlAuthContext,
|
||||
) {
|
||||
log_auth_context_resolution(trace_id, decision, &auth_context);
|
||||
decision.local_auth_rejection = auth_context.local_rejection.clone();
|
||||
if !auth_context.user_id.is_empty() && !auth_context.api_key_id.is_empty() {
|
||||
decision.auth_context = Some(auth_context);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn resolve_data_backed_auth_context(
|
||||
state: &AppState,
|
||||
headers: &http::HeaderMap,
|
||||
@@ -600,10 +709,8 @@ pub(super) async fn resolve_data_backed_auth_context(
|
||||
}
|
||||
Some(GatewayPrincipalCandidate::ApiKeyHash { key_hash, .. }) => {
|
||||
let snapshot = state
|
||||
.data
|
||||
.read_auth_api_key_snapshot_by_key_hash(&key_hash, now_unix_secs)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?;
|
||||
.read_cached_auth_api_key_snapshot_by_key_hash(&key_hash, now_unix_secs)
|
||||
.await?;
|
||||
let Some(snapshot) = snapshot else {
|
||||
return Ok(Some(GatewayControlAuthContext {
|
||||
user_id: String::new(),
|
||||
@@ -1052,6 +1159,15 @@ fn endpoint_matches_requested_provider(
|
||||
}
|
||||
|
||||
fn get_cached_auth_context(state: &AppState, cache_key: &str) -> Option<GatewayControlAuthContext> {
|
||||
let negative_ttl = auth_context_negative_cache_ttl();
|
||||
if !negative_ttl.is_zero() {
|
||||
if let Some(auth_context) = state
|
||||
.auth_context_cache
|
||||
.get_fresh(&negative_auth_context_cache_key(cache_key), negative_ttl)
|
||||
{
|
||||
return Some(auth_context);
|
||||
}
|
||||
}
|
||||
state
|
||||
.auth_context_cache
|
||||
.get_fresh(cache_key, AUTH_CONTEXT_CACHE_TTL)
|
||||
@@ -1074,10 +1190,11 @@ mod tests {
|
||||
use axum::http::{HeaderMap, Uri};
|
||||
|
||||
use super::{
|
||||
resolve_data_backed_auth_context, resolve_execution_runtime_auth_context,
|
||||
get_cached_auth_context, resolve_control_decision_auth, resolve_data_backed_auth_context,
|
||||
resolve_execution_runtime_auth_context, ControlDecisionAuthResolution,
|
||||
GatewayLocalAuthRejection,
|
||||
};
|
||||
use crate::control::auth::credentials::hash_api_key;
|
||||
use crate::control::auth::credentials::{build_auth_context_cache_key, hash_api_key};
|
||||
use crate::control::GatewayControlDecision;
|
||||
use crate::data::GatewayDataState;
|
||||
use crate::AppState;
|
||||
@@ -1139,6 +1256,53 @@ mod tests {
|
||||
.expect("endpoint should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn control_auth_caches_invalid_api_key_rejections() {
|
||||
let repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed([]));
|
||||
let data = GatewayDataState::with_auth_api_key_repository_for_tests(repository);
|
||||
let state = AppState::new()
|
||||
.expect("state should build")
|
||||
.with_data_state_for_tests(data);
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
http::header::AUTHORIZATION,
|
||||
"Bearer sk-missing-for-negative-cache".parse().unwrap(),
|
||||
);
|
||||
let request_uri = uri("/v1/chat/completions");
|
||||
let decision = GatewayControlDecision::synthetic(
|
||||
"/v1/chat/completions",
|
||||
Some("ai_public".to_string()),
|
||||
Some("openai".to_string()),
|
||||
Some("chat".to_string()),
|
||||
Some("openai:chat".to_string()),
|
||||
);
|
||||
|
||||
let ControlDecisionAuthResolution::Resolved(first) = resolve_control_decision_auth(
|
||||
&state,
|
||||
&headers,
|
||||
&request_uri,
|
||||
"trace-invalid-auth-cache",
|
||||
decision,
|
||||
)
|
||||
.await
|
||||
.expect("auth resolution should succeed");
|
||||
|
||||
assert_eq!(
|
||||
first.local_auth_rejection,
|
||||
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
||||
);
|
||||
let cache_key = build_auth_context_cache_key(&headers, &request_uri, "openai:chat")
|
||||
.expect("cache key should exist");
|
||||
let cached = get_cached_auth_context(&state, &cache_key)
|
||||
.expect("invalid API key rejection should be cached");
|
||||
assert_eq!(
|
||||
cached.local_rejection,
|
||||
Some(GatewayLocalAuthRejection::InvalidApiKey)
|
||||
);
|
||||
assert!(cached.user_id.is_empty());
|
||||
assert!(cached.api_key_id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn data_backed_api_key_auth_touches_last_used_once_per_throttle_window() {
|
||||
let api_key = "sk-test-touch";
|
||||
@@ -1326,7 +1490,8 @@ mod tests {
|
||||
second.local_rejection,
|
||||
Some(GatewayLocalAuthRejection::BalanceDenied {
|
||||
remaining: Some(0.0),
|
||||
})
|
||||
}),
|
||||
"cached auth context should revalidate wallet state before execution"
|
||||
);
|
||||
assert!(!second.access_allowed);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_cache::ExpiringMap;
|
||||
use aether_data::repository::auth::*;
|
||||
use aether_data::DataLayerError;
|
||||
use async_trait::async_trait;
|
||||
|
||||
const AUTH_API_KEY_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
const AUTH_API_KEY_SNAPSHOT_CACHE_MAX_ENTRIES: usize = 16_384;
|
||||
|
||||
pub(super) struct CachedAuthApiKeyReadRepository {
|
||||
inner: Arc<dyn AuthApiKeyReadRepository>,
|
||||
snapshots: ExpiringMap<AuthApiKeySnapshotCacheKey, Option<StoredAuthApiKeySnapshot>>,
|
||||
load_guard: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl CachedAuthApiKeyReadRepository {
|
||||
pub(super) fn new(inner: Arc<dyn AuthApiKeyReadRepository>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
snapshots: ExpiringMap::new(),
|
||||
load_guard: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_key(key: AuthApiKeyLookupKey<'_>) -> AuthApiKeySnapshotCacheKey {
|
||||
match key {
|
||||
AuthApiKeyLookupKey::KeyHash(value) => {
|
||||
AuthApiKeySnapshotCacheKey::KeyHash(value.to_string())
|
||||
}
|
||||
AuthApiKeyLookupKey::ApiKeyId(value) => {
|
||||
AuthApiKeySnapshotCacheKey::ApiKeyId(value.to_string())
|
||||
}
|
||||
AuthApiKeyLookupKey::UserApiKeyIds {
|
||||
user_id,
|
||||
api_key_id,
|
||||
} => AuthApiKeySnapshotCacheKey::UserApiKeyIds {
|
||||
user_id: user_id.to_string(),
|
||||
api_key_id: api_key_id.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum AuthApiKeySnapshotCacheKey {
|
||||
KeyHash(String),
|
||||
ApiKeyId(String),
|
||||
UserApiKeyIds { user_id: String, api_key_id: String },
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl AuthApiKeyReadRepository for CachedAuthApiKeyReadRepository {
|
||||
async fn find_api_key_snapshot(
|
||||
&self,
|
||||
key: AuthApiKeyLookupKey<'_>,
|
||||
) -> Result<Option<StoredAuthApiKeySnapshot>, DataLayerError> {
|
||||
let cache_key = Self::cache_key(key);
|
||||
if let Some(value) = self
|
||||
.snapshots
|
||||
.get_fresh(&cache_key, AUTH_API_KEY_SNAPSHOT_CACHE_TTL)
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let _guard = self.load_guard.lock().await;
|
||||
if let Some(value) = self
|
||||
.snapshots
|
||||
.get_fresh(&cache_key, AUTH_API_KEY_SNAPSHOT_CACHE_TTL)
|
||||
{
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let value = self.inner.find_api_key_snapshot(key).await?;
|
||||
self.snapshots.insert(
|
||||
cache_key,
|
||||
value.clone(),
|
||||
AUTH_API_KEY_SNAPSHOT_CACHE_TTL,
|
||||
AUTH_API_KEY_SNAPSHOT_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
async fn list_api_key_snapshots_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<Vec<StoredAuthApiKeySnapshot>, DataLayerError> {
|
||||
let mut snapshots = Vec::with_capacity(api_key_ids.len());
|
||||
for api_key_id in api_key_ids {
|
||||
if let Some(snapshot) = self
|
||||
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId(api_key_id))
|
||||
.await?
|
||||
{
|
||||
snapshots.push(snapshot);
|
||||
}
|
||||
}
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
async fn list_export_api_keys_by_user_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
) -> Result<Vec<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
self.inner.list_export_api_keys_by_user_ids(user_ids).await
|
||||
}
|
||||
|
||||
async fn list_export_api_keys_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
) -> Result<Vec<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
self.inner.list_export_api_keys_by_ids(api_key_ids).await
|
||||
}
|
||||
|
||||
async fn list_export_api_keys_by_name_search(
|
||||
&self,
|
||||
name_search: &str,
|
||||
) -> Result<Vec<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
self.inner
|
||||
.list_export_api_keys_by_name_search(name_search)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_export_standalone_api_keys_page(
|
||||
&self,
|
||||
query: &StandaloneApiKeyExportListQuery,
|
||||
) -> Result<Vec<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
self.inner.list_export_standalone_api_keys_page(query).await
|
||||
}
|
||||
|
||||
async fn count_export_standalone_api_keys(
|
||||
&self,
|
||||
is_active: Option<bool>,
|
||||
) -> Result<u64, DataLayerError> {
|
||||
self.inner.count_export_standalone_api_keys(is_active).await
|
||||
}
|
||||
|
||||
async fn summarize_export_api_keys_by_user_ids(
|
||||
&self,
|
||||
user_ids: &[String],
|
||||
now_unix_secs: u64,
|
||||
) -> Result<AuthApiKeyExportSummary, DataLayerError> {
|
||||
self.inner
|
||||
.summarize_export_api_keys_by_user_ids(user_ids, now_unix_secs)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn summarize_export_non_standalone_api_keys(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<AuthApiKeyExportSummary, DataLayerError> {
|
||||
self.inner
|
||||
.summarize_export_non_standalone_api_keys(now_unix_secs)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn summarize_export_standalone_api_keys(
|
||||
&self,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<AuthApiKeyExportSummary, DataLayerError> {
|
||||
self.inner
|
||||
.summarize_export_standalone_api_keys(now_unix_secs)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn find_export_standalone_api_key_by_id(
|
||||
&self,
|
||||
api_key_id: &str,
|
||||
) -> Result<Option<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
self.inner
|
||||
.find_export_standalone_api_key_by_id(api_key_id)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn list_export_standalone_api_keys(
|
||||
&self,
|
||||
) -> Result<Vec<StoredAuthApiKeyExportRecord>, DataLayerError> {
|
||||
self.inner.list_export_standalone_api_keys().await
|
||||
}
|
||||
}
|
||||
@@ -406,6 +406,7 @@ mod tests {
|
||||
struct StubCandidateSelectionRepository {
|
||||
calls: AtomicUsize,
|
||||
delay: Duration,
|
||||
rows: Vec<StoredMinimalCandidateSelectionRow>,
|
||||
}
|
||||
|
||||
impl StubCandidateSelectionRepository {
|
||||
@@ -413,6 +414,7 @@ mod tests {
|
||||
Self {
|
||||
calls: AtomicUsize::new(0),
|
||||
delay,
|
||||
rows: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,7 +427,7 @@ mod tests {
|
||||
if !self.delay.is_zero() {
|
||||
tokio::time::sleep(self.delay).await;
|
||||
}
|
||||
Ok(Vec::new())
|
||||
Ok(self.rows.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -697,4 +699,84 @@ mod tests {
|
||||
leader.abort();
|
||||
let _ = leader.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn candidate_selection_load_balance_cache_keeps_seed_specific_entries() {
|
||||
let inner = Arc::new(StubCandidateSelectionRepository {
|
||||
calls: AtomicUsize::new(0),
|
||||
delay: Duration::ZERO,
|
||||
rows: vec![
|
||||
sample_row("key-a", 1),
|
||||
sample_row("key-b", 2),
|
||||
sample_row("key-c", 3),
|
||||
],
|
||||
});
|
||||
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner.clone());
|
||||
let query = |seed: &str| StoredPoolKeyCandidateRowsQuery {
|
||||
api_format: "openai:chat".to_string(),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
model_id: "model-1".to_string(),
|
||||
selected_provider_model_name: "mock-model".to_string(),
|
||||
order: StoredPoolKeyCandidateOrder::LoadBalance {
|
||||
seed: seed.to_string(),
|
||||
},
|
||||
offset: 0,
|
||||
limit: 2,
|
||||
};
|
||||
|
||||
let first = cache
|
||||
.list_pool_key_rows_for_group(&query("seed-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
let second = cache
|
||||
.list_pool_key_rows_for_group(&query("seed-b"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(inner.calls(), 2);
|
||||
assert_eq!(first.len(), 3);
|
||||
assert_eq!(second.len(), 3);
|
||||
|
||||
let third = cache
|
||||
.list_pool_key_rows_for_group(&query("seed-a"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(inner.calls(), 2);
|
||||
assert_eq!(third.len(), 3);
|
||||
}
|
||||
|
||||
fn sample_row(key_id: &str, key_internal_priority: i32) -> StoredMinimalCandidateSelectionRow {
|
||||
StoredMinimalCandidateSelectionRow {
|
||||
provider_id: "provider-1".to_string(),
|
||||
provider_name: "provider".to_string(),
|
||||
provider_type: "custom".to_string(),
|
||||
provider_priority: 1,
|
||||
provider_is_active: true,
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
endpoint_api_format: "openai:chat".to_string(),
|
||||
endpoint_api_family: Some("openai".to_string()),
|
||||
endpoint_kind: Some("chat".to_string()),
|
||||
endpoint_is_active: true,
|
||||
key_id: key_id.to_string(),
|
||||
key_name: key_id.to_string(),
|
||||
key_auth_type: "api_key".to_string(),
|
||||
key_is_active: true,
|
||||
key_api_formats: Some(vec!["openai:chat".to_string()]),
|
||||
key_allowed_models: None,
|
||||
key_capabilities: None,
|
||||
key_internal_priority,
|
||||
key_global_priority_by_format: None,
|
||||
model_id: "model-1".to_string(),
|
||||
global_model_id: "global-model-1".to_string(),
|
||||
global_model_name: "mock-model".to_string(),
|
||||
global_model_mappings: None,
|
||||
global_model_supports_streaming: Some(true),
|
||||
model_provider_model_name: "mock-model".to_string(),
|
||||
model_provider_model_mappings: None,
|
||||
model_supports_streaming: Some(true),
|
||||
model_is_active: true,
|
||||
model_is_available: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
use aether_data::{DataBackends, DataLayerError, DatabaseDriver};
|
||||
use aether_data_contracts::repository::candidate_selection::MinimalCandidateSelectionReadRepository;
|
||||
use aether_data_contracts::repository::candidates::RequestCandidateReadRepository;
|
||||
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
|
||||
use aether_runtime_state::RuntimeQueueStore;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{GatewayDataConfig, GatewayDataState, StoredSystemConfigEntry};
|
||||
|
||||
const SYSTEM_CONFIG_VALUE_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
fn system_config_value_load_guard() -> &'static Mutex<()> {
|
||||
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
GUARD.get_or_init(|| Mutex::new(()))
|
||||
}
|
||||
|
||||
fn current_system_config_updated_at_unix_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -64,11 +74,16 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
});
|
||||
}
|
||||
|
||||
let backends = DataBackends::from_config(config.to_data_layer_config())?;
|
||||
let auth_api_key_reader = backends.read().auth_api_keys();
|
||||
let auth_api_key_reader = backends.read().auth_api_keys().map(|repository| {
|
||||
Arc::new(super::auth_api_key_cache::CachedAuthApiKeyReadRepository::new(repository))
|
||||
as Arc<dyn aether_data::repository::auth::AuthApiKeyReadRepository>
|
||||
});
|
||||
let auth_api_key_writer = backends.write().auth_api_keys();
|
||||
let auth_module_reader = backends.read().auth_modules();
|
||||
let auth_module_writer = backends.write().auth_modules();
|
||||
@@ -97,7 +112,13 @@ impl GatewayDataState {
|
||||
),
|
||||
) as Arc<dyn MinimalCandidateSelectionReadRepository>
|
||||
});
|
||||
let request_candidate_reader = backends.read().request_candidates();
|
||||
let request_candidate_reader = backends.read().request_candidates().map(|repository| {
|
||||
Arc::new(
|
||||
super::request_candidate_cache::CachedRequestCandidateReadRepository::new(
|
||||
repository,
|
||||
),
|
||||
) as Arc<dyn RequestCandidateReadRepository>
|
||||
});
|
||||
let request_candidate_writer = backends.write().request_candidates();
|
||||
let gemini_file_mapping_writer = backends.write().gemini_file_mappings();
|
||||
let provider_catalog_reader = backends.read().provider_catalog().map(|repository| {
|
||||
@@ -110,7 +131,11 @@ impl GatewayDataState {
|
||||
let pool_score_writer = backends.write().pool_scores();
|
||||
let provider_quota_reader = backends.read().provider_quotas();
|
||||
let provider_quota_writer = backends.write().provider_quotas();
|
||||
let routing_group_reader = backends.read().routing_groups();
|
||||
let routing_group_reader = backends.read().routing_groups().map(|repository| {
|
||||
Arc::new(super::routing_group_cache::CachedRoutingGroupReadRepository::new(
|
||||
repository,
|
||||
)) as Arc<dyn aether_data_contracts::repository::routing_profiles::RoutingGroupReadRepository>
|
||||
});
|
||||
let routing_group_writer = backends.write().routing_groups();
|
||||
let usage_reader = backends.read().usage();
|
||||
let usage_writer = backends.write().usage();
|
||||
@@ -166,6 +191,8 @@ impl GatewayDataState {
|
||||
wallet_writer,
|
||||
settlement_writer,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -401,10 +428,38 @@ impl GatewayDataState {
|
||||
.get(key)
|
||||
.map(|entry| entry.value.clone()));
|
||||
}
|
||||
let cached_value = self
|
||||
.system_config_value_cache
|
||||
.read()
|
||||
.expect("system config value cache lock")
|
||||
.get(key)
|
||||
.cloned();
|
||||
if let Some((cached_at, value)) = cached_value {
|
||||
if cached_at.elapsed() <= SYSTEM_CONFIG_VALUE_CACHE_TTL {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
let _guard = system_config_value_load_guard().lock().await;
|
||||
let cached_value = self
|
||||
.system_config_value_cache
|
||||
.read()
|
||||
.expect("system config value cache lock")
|
||||
.get(key)
|
||||
.cloned();
|
||||
if let Some((cached_at, value)) = cached_value {
|
||||
if cached_at.elapsed() <= SYSTEM_CONFIG_VALUE_CACHE_TTL {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
let Some(backends) = self.backends.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
backends.find_system_config_value(key).await
|
||||
let value = backends.find_system_config_value(key).await?;
|
||||
self.system_config_value_cache
|
||||
.write()
|
||||
.expect("system config value cache lock")
|
||||
.insert(key.to_string(), (Instant::now(), value.clone()));
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_system_config_value(
|
||||
@@ -454,6 +509,7 @@ impl GatewayDataState {
|
||||
updated_at_unix_secs: Some(current_system_config_updated_at_unix_secs()),
|
||||
};
|
||||
values.insert(key.to_string(), entry.clone());
|
||||
self.clear_cached_system_config_value(key);
|
||||
return Ok(entry);
|
||||
}
|
||||
if let Some(backends) = self.backends.as_ref() {
|
||||
@@ -461,9 +517,11 @@ impl GatewayDataState {
|
||||
.upsert_system_config_entry(key, value, description)
|
||||
.await?
|
||||
{
|
||||
self.clear_cached_system_config_value(key);
|
||||
return Ok(entry);
|
||||
}
|
||||
}
|
||||
self.clear_cached_system_config_value(key);
|
||||
Ok(StoredSystemConfigEntry {
|
||||
key: key.to_string(),
|
||||
value: value.clone(),
|
||||
@@ -477,16 +535,27 @@ impl GatewayDataState {
|
||||
key: &str,
|
||||
) -> Result<bool, DataLayerError> {
|
||||
if let Some(values) = &self.system_config_values {
|
||||
return Ok(values
|
||||
let deleted = values
|
||||
.write()
|
||||
.expect("system config values lock")
|
||||
.remove(key)
|
||||
.is_some());
|
||||
.is_some();
|
||||
self.clear_cached_system_config_value(key);
|
||||
return Ok(deleted);
|
||||
}
|
||||
let Some(backends) = self.backends.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
backends.delete_system_config_value(key).await
|
||||
let deleted = backends.delete_system_config_value(key).await?;
|
||||
self.clear_cached_system_config_value(key);
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
fn clear_cached_system_config_value(&self, key: &str) {
|
||||
self.system_config_value_cache
|
||||
.write()
|
||||
.expect("system config value cache lock")
|
||||
.remove(key);
|
||||
}
|
||||
|
||||
pub(crate) async fn read_admin_system_stats(
|
||||
|
||||
@@ -253,12 +253,15 @@ impl UsageRuntimeAccess for GatewayDataState {
|
||||
}
|
||||
|
||||
async fn body_capture_policy(&self) -> Result<UsageBodyCapturePolicy, DataLayerError> {
|
||||
let value = GatewayDataState::find_system_config_value(self, REQUEST_RECORD_LEVEL_KEY)
|
||||
let value = match GatewayDataState::find_system_config_value(self, REQUEST_RECORD_LEVEL_KEY)
|
||||
.await?
|
||||
.or(
|
||||
{
|
||||
Some(value) => Some(value),
|
||||
None => {
|
||||
GatewayDataState::find_system_config_value(self, LEGACY_REQUEST_LOG_LEVEL_KEY)
|
||||
.await?,
|
||||
);
|
||||
.await?
|
||||
}
|
||||
};
|
||||
let max_request_body_size =
|
||||
GatewayDataState::find_system_config_value(self, MAX_REQUEST_BODY_SIZE_KEY).await?;
|
||||
let max_response_body_size =
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Instant;
|
||||
|
||||
use super::auth::GatewayAuthApiKeySnapshot;
|
||||
use super::candidates::{read_request_candidate_trace, RequestCandidateTrace};
|
||||
@@ -194,6 +196,24 @@ pub(crate) struct GatewayDataState {
|
||||
wallet_writer: Option<Arc<dyn WalletWriteRepository>>,
|
||||
settlement_writer: Option<Arc<dyn SettlementWriteRepository>>,
|
||||
system_config_values: Option<Arc<RwLock<BTreeMap<String, StoredSystemConfigEntry>>>>,
|
||||
system_config_value_cache: Arc<RwLock<BTreeMap<String, (Instant, Option<serde_json::Value>)>>>,
|
||||
billing_model_context_cache: Arc<
|
||||
RwLock<HashMap<BillingModelContextCacheKey, (Instant, Option<StoredBillingModelContext>)>>,
|
||||
>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
pub(super) enum BillingModelContextCacheKey {
|
||||
ByModelId {
|
||||
provider_id: String,
|
||||
provider_api_key_id: Option<String>,
|
||||
model_id: String,
|
||||
},
|
||||
ByGlobalModelName {
|
||||
provider_id: String,
|
||||
provider_api_key_id: Option<String>,
|
||||
global_model_name: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Debug for GatewayDataState {
|
||||
@@ -318,6 +338,7 @@ impl fmt::Debug for GatewayDataState {
|
||||
}
|
||||
|
||||
mod auth;
|
||||
mod auth_api_key_cache;
|
||||
mod candidate_cache;
|
||||
mod catalog;
|
||||
mod core;
|
||||
@@ -326,6 +347,8 @@ mod models;
|
||||
mod pool_scores;
|
||||
mod provider_catalog_cache;
|
||||
mod referrals;
|
||||
mod request_candidate_cache;
|
||||
mod routing_group_cache;
|
||||
mod routing_profiles;
|
||||
mod runtime;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -174,7 +174,19 @@ impl ProviderCatalogReadRepository for CachedProviderCatalogReadRepository {
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderCatalogEndpoint>, DataLayerError> {
|
||||
self.inner.list_endpoints_by_ids(endpoint_ids).await
|
||||
let key = ProviderCatalogCacheKey::EndpointsByIds(normalize_ids(endpoint_ids));
|
||||
match self
|
||||
.get_or_load(key, || async move {
|
||||
self.inner
|
||||
.list_endpoints_by_ids(endpoint_ids)
|
||||
.await
|
||||
.map(ProviderCatalogCacheValue::Endpoints)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
ProviderCatalogCacheValue::Endpoints(items) => Ok(items),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_endpoints_by_provider_ids(
|
||||
@@ -200,7 +212,19 @@ impl ProviderCatalogReadRepository for CachedProviderCatalogReadRepository {
|
||||
&self,
|
||||
key_ids: &[String],
|
||||
) -> Result<Vec<StoredProviderCatalogKey>, DataLayerError> {
|
||||
self.inner.list_keys_by_ids(key_ids).await
|
||||
let key = ProviderCatalogCacheKey::KeysByIds(normalize_ids(key_ids));
|
||||
match self
|
||||
.get_or_load(key, || async move {
|
||||
self.inner
|
||||
.list_keys_by_ids(key_ids)
|
||||
.await
|
||||
.map(ProviderCatalogCacheValue::Keys)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
ProviderCatalogCacheValue::Keys(items) => Ok(items),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_keys_by_provider_ids(
|
||||
@@ -293,7 +317,9 @@ impl ProviderCatalogReadRepository for CachedProviderCatalogReadRepository {
|
||||
enum ProviderCatalogCacheKey {
|
||||
Providers { active_only: bool },
|
||||
ProvidersByIds(Vec<String>),
|
||||
EndpointsByIds(Vec<String>),
|
||||
EndpointsByProviderIds(Vec<String>),
|
||||
KeysByIds(Vec<String>),
|
||||
KeysByProviderIds(Vec<String>),
|
||||
KeySummariesByProviderIds(Vec<String>),
|
||||
KeyMaintenanceSummariesByProviderIds(Vec<String>),
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_data_contracts::repository::candidates::{
|
||||
PublicHealthStatusCount, PublicHealthTimelineBucket, RequestCandidateReadRepository,
|
||||
StoredRequestCandidate,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
const RECENT_REQUEST_CANDIDATES_TTL: Duration = Duration::from_millis(100);
|
||||
const RECENT_REQUEST_CANDIDATES_MAX_CACHE_KEYS: usize = 8;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct CachedRequestCandidateReadRepository {
|
||||
inner: Arc<dyn RequestCandidateReadRepository>,
|
||||
recent: Arc<RwLock<BTreeMap<usize, (Instant, Vec<StoredRequestCandidate>)>>>,
|
||||
recent_load_guard: Arc<tokio::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
impl CachedRequestCandidateReadRepository {
|
||||
pub(super) fn new(inner: Arc<dyn RequestCandidateReadRepository>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
recent: Default::default(),
|
||||
recent_load_guard: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_recent(&self, limit: usize, now: Instant) -> Option<Vec<StoredRequestCandidate>> {
|
||||
self.recent
|
||||
.read()
|
||||
.expect("recent request candidates cache lock")
|
||||
.get(&limit)
|
||||
.filter(|(loaded_at, _)| {
|
||||
now.duration_since(*loaded_at) <= RECENT_REQUEST_CANDIDATES_TTL
|
||||
})
|
||||
.map(|(_, rows)| rows.clone())
|
||||
}
|
||||
|
||||
fn store_recent(&self, limit: usize, rows: &[StoredRequestCandidate], now: Instant) {
|
||||
let mut cache = self
|
||||
.recent
|
||||
.write()
|
||||
.expect("recent request candidates cache lock");
|
||||
if cache.len() >= RECENT_REQUEST_CANDIDATES_MAX_CACHE_KEYS && !cache.contains_key(&limit) {
|
||||
if let Some(oldest_key) = cache
|
||||
.iter()
|
||||
.min_by_key(|(_, (loaded_at, _))| *loaded_at)
|
||||
.map(|(key, _)| *key)
|
||||
{
|
||||
cache.remove(&oldest_key);
|
||||
}
|
||||
}
|
||||
cache.insert(limit, (now, rows.to_vec()));
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RequestCandidateReadRepository for CachedRequestCandidateReadRepository {
|
||||
async fn list_by_request_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<StoredRequestCandidate>, aether_data::DataLayerError> {
|
||||
self.inner.list_by_request_id(request_id).await
|
||||
}
|
||||
|
||||
async fn list_attempted_by_request_id(
|
||||
&self,
|
||||
request_id: &str,
|
||||
) -> Result<Vec<StoredRequestCandidate>, aether_data::DataLayerError> {
|
||||
self.inner.list_attempted_by_request_id(request_id).await
|
||||
}
|
||||
|
||||
async fn list_recent(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredRequestCandidate>, aether_data::DataLayerError> {
|
||||
if limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let now = Instant::now();
|
||||
if let Some(rows) = self.cached_recent(limit, now) {
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
let _guard = self.recent_load_guard.lock().await;
|
||||
let now = Instant::now();
|
||||
if let Some(rows) = self.cached_recent(limit, now) {
|
||||
return Ok(rows);
|
||||
}
|
||||
|
||||
let rows = self.inner.list_recent(limit).await?;
|
||||
self.store_recent(limit, &rows, now);
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
async fn list_by_provider_id(
|
||||
&self,
|
||||
provider_id: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredRequestCandidate>, aether_data::DataLayerError> {
|
||||
self.inner.list_by_provider_id(provider_id, limit).await
|
||||
}
|
||||
|
||||
async fn list_finalized_by_endpoint_ids_since(
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
since_unix_secs: u64,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredRequestCandidate>, aether_data::DataLayerError> {
|
||||
self.inner
|
||||
.list_finalized_by_endpoint_ids_since(endpoint_ids, since_unix_secs, limit)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn count_finalized_statuses_by_endpoint_ids_since(
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
since_unix_secs: u64,
|
||||
) -> Result<Vec<PublicHealthStatusCount>, aether_data::DataLayerError> {
|
||||
self.inner
|
||||
.count_finalized_statuses_by_endpoint_ids_since(endpoint_ids, since_unix_secs)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn aggregate_finalized_timeline_by_endpoint_ids_since(
|
||||
&self,
|
||||
endpoint_ids: &[String],
|
||||
since_unix_secs: u64,
|
||||
until_unix_secs: u64,
|
||||
segments: u32,
|
||||
) -> Result<Vec<PublicHealthTimelineBucket>, aether_data::DataLayerError> {
|
||||
self.inner
|
||||
.aggregate_finalized_timeline_by_endpoint_ids_since(
|
||||
endpoint_ids,
|
||||
since_unix_secs,
|
||||
until_unix_secs,
|
||||
segments,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_cache::ExpiringMap;
|
||||
use aether_data::DataLayerError;
|
||||
use aether_data_contracts::repository::routing_profiles::{
|
||||
RoutingGroupBindingQuery, RoutingGroupBindingSubject, RoutingGroupLookupKey,
|
||||
RoutingGroupReadRepository, StoredRoutingGroup, StoredRoutingGroupBinding,
|
||||
StoredRoutingGroupVersion,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
|
||||
const ROUTING_GROUP_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
const ROUTING_GROUP_CACHE_MAX_ENTRIES: usize = 4_096;
|
||||
|
||||
pub(super) struct CachedRoutingGroupReadRepository {
|
||||
inner: Arc<dyn RoutingGroupReadRepository>,
|
||||
entries: ExpiringMap<RoutingGroupCacheKey, RoutingGroupCacheValue>,
|
||||
load_guard: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
impl CachedRoutingGroupReadRepository {
|
||||
pub(super) fn new(inner: Arc<dyn RoutingGroupReadRepository>) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
entries: ExpiringMap::new(),
|
||||
load_guard: tokio::sync::Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_or_load(
|
||||
&self,
|
||||
key: RoutingGroupCacheKey,
|
||||
load: impl std::future::Future<Output = Result<RoutingGroupCacheValue, DataLayerError>>,
|
||||
) -> Result<RoutingGroupCacheValue, DataLayerError> {
|
||||
if let Some(value) = self.entries.get_fresh(&key, ROUTING_GROUP_CACHE_TTL) {
|
||||
return Ok(value);
|
||||
}
|
||||
let _guard = self.load_guard.lock().await;
|
||||
if let Some(value) = self.entries.get_fresh(&key, ROUTING_GROUP_CACHE_TTL) {
|
||||
return Ok(value);
|
||||
}
|
||||
let value = load.await?;
|
||||
self.entries.insert(
|
||||
key,
|
||||
value.clone(),
|
||||
ROUTING_GROUP_CACHE_TTL,
|
||||
ROUTING_GROUP_CACHE_MAX_ENTRIES,
|
||||
);
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
enum RoutingGroupCacheKey {
|
||||
ListGroups,
|
||||
FindById(String),
|
||||
FindByName(String),
|
||||
FindSystemDefault,
|
||||
Bindings {
|
||||
group_id: Option<String>,
|
||||
subject_type: Option<&'static str>,
|
||||
subject_id: Option<String>,
|
||||
},
|
||||
Versions(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
enum RoutingGroupCacheValue {
|
||||
Groups(Vec<StoredRoutingGroup>),
|
||||
Group(Option<StoredRoutingGroup>),
|
||||
Bindings(Vec<StoredRoutingGroupBinding>),
|
||||
Versions(Vec<StoredRoutingGroupVersion>),
|
||||
}
|
||||
|
||||
fn lookup_cache_key(lookup: &RoutingGroupLookupKey<'_>) -> RoutingGroupCacheKey {
|
||||
match lookup {
|
||||
RoutingGroupLookupKey::Id(id) => RoutingGroupCacheKey::FindById((*id).to_string()),
|
||||
RoutingGroupLookupKey::Name(name) => RoutingGroupCacheKey::FindByName((*name).to_string()),
|
||||
RoutingGroupLookupKey::SystemDefault => RoutingGroupCacheKey::FindSystemDefault,
|
||||
}
|
||||
}
|
||||
|
||||
fn subject_cache_key(subject: Option<RoutingGroupBindingSubject>) -> Option<&'static str> {
|
||||
match subject {
|
||||
Some(RoutingGroupBindingSubject::User) => Some("user"),
|
||||
Some(RoutingGroupBindingSubject::ApiKey) => Some("api_key"),
|
||||
Some(RoutingGroupBindingSubject::UserGroup) => Some("user_group"),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl RoutingGroupReadRepository for CachedRoutingGroupReadRepository {
|
||||
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
|
||||
match self
|
||||
.get_or_load(RoutingGroupCacheKey::ListGroups, async {
|
||||
self.inner
|
||||
.list_routing_groups()
|
||||
.await
|
||||
.map(RoutingGroupCacheValue::Groups)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RoutingGroupCacheValue::Groups(groups) => Ok(groups),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn find_routing_group(
|
||||
&self,
|
||||
lookup: RoutingGroupLookupKey<'_>,
|
||||
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
|
||||
let key = lookup_cache_key(&lookup);
|
||||
match self
|
||||
.get_or_load(key, async {
|
||||
self.inner
|
||||
.find_routing_group(lookup)
|
||||
.await
|
||||
.map(RoutingGroupCacheValue::Group)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RoutingGroupCacheValue::Group(group) => Ok(group),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_routing_group_bindings(
|
||||
&self,
|
||||
query: &RoutingGroupBindingQuery,
|
||||
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
|
||||
let key = RoutingGroupCacheKey::Bindings {
|
||||
group_id: query.group_id.clone(),
|
||||
subject_type: subject_cache_key(query.subject_type),
|
||||
subject_id: query.subject_id.clone(),
|
||||
};
|
||||
match self
|
||||
.get_or_load(key, async {
|
||||
self.inner
|
||||
.list_routing_group_bindings(query)
|
||||
.await
|
||||
.map(RoutingGroupCacheValue::Bindings)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RoutingGroupCacheValue::Bindings(bindings) => Ok(bindings),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_routing_group_versions(
|
||||
&self,
|
||||
group_id: &str,
|
||||
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
|
||||
let key = RoutingGroupCacheKey::Versions(group_id.to_string());
|
||||
match self
|
||||
.get_or_load(key, async {
|
||||
self.inner
|
||||
.list_routing_group_versions(group_id)
|
||||
.await
|
||||
.map(RoutingGroupCacheValue::Versions)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
RoutingGroupCacheValue::Versions(versions) => Ok(versions),
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,8 @@ use super::{
|
||||
AdminBillingRuleWriteInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
|
||||
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
|
||||
AdminWalletRefundRequestListQuery, AnnouncementListQuery, AuditLogListQuery,
|
||||
BackgroundTaskListQuery, BackgroundTaskSummary, BillingPlanRecord, BillingPlanWriteInput,
|
||||
CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||
BackgroundTaskListQuery, BackgroundTaskSummary, BillingModelContextCacheKey, BillingPlanRecord,
|
||||
BillingPlanWriteInput, CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
|
||||
CreateAdminRedeemCodeBatchResult, CreateAnnouncementRecord, CreateManualWalletRechargeInput,
|
||||
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
|
||||
CreateWalletRechargeOrderOutcome, CreateWalletRefundRequestInput,
|
||||
@@ -44,9 +44,23 @@ use aether_runtime_state::RuntimeQueueStore;
|
||||
use aether_video_tasks_core::read_data_backed_video_task_response;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn normalize_billing_context_cache_part(value: &str) -> String {
|
||||
value.trim().to_string()
|
||||
}
|
||||
|
||||
fn normalize_optional_billing_context_cache_part(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
impl GatewayDataState {
|
||||
const MAINTENANCE_POOL_IDLE_RESERVE: usize = 1;
|
||||
const MAINTENANCE_POOL_IDLE_RESERVE_ENV: &'static str =
|
||||
"AETHER_GATEWAY_MAINTENANCE_POOL_IDLE_RESERVE";
|
||||
const MAINTENANCE_POOL_PRESSURE_MAX_DEFER: Duration = Duration::from_secs(30);
|
||||
const BILLING_MODEL_CONTEXT_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
const BILLING_MODEL_CONTEXT_CACHE_MAX_ENTRIES: usize = 4096;
|
||||
|
||||
pub(crate) async fn run_database_maintenance(
|
||||
&self,
|
||||
@@ -125,7 +139,26 @@ impl GatewayDataState {
|
||||
pub(crate) fn database_pool_summary_under_maintenance_pressure(
|
||||
summary: &aether_data::DatabasePoolSummary,
|
||||
) -> bool {
|
||||
summary.checked_out > 0 && summary.idle <= Self::MAINTENANCE_POOL_IDLE_RESERVE
|
||||
summary.checked_out > 0 && summary.idle <= Self::maintenance_pool_idle_reserve(summary)
|
||||
}
|
||||
|
||||
pub(crate) fn maintenance_pool_idle_reserve(
|
||||
summary: &aether_data::DatabasePoolSummary,
|
||||
) -> usize {
|
||||
if let Some(override_value) = std::env::var(Self::MAINTENANCE_POOL_IDLE_RESERVE_ENV)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
{
|
||||
return override_value;
|
||||
}
|
||||
|
||||
let max_connections = summary.max_connections as usize;
|
||||
if max_connections == 0 {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let ten_percent_ceil = (max_connections + 9) / 10;
|
||||
ten_percent_ceil.clamp(2, 10).min(max_connections)
|
||||
}
|
||||
|
||||
pub(crate) fn should_defer_maintenance_for_database_pool_pressure(
|
||||
@@ -1615,15 +1648,14 @@ impl GatewayDataState {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, DataLayerError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(user) = self.find_export_user_by_id(user_id).await? {
|
||||
return Ok(user.feature_settings);
|
||||
}
|
||||
Ok(self
|
||||
.list_non_admin_export_users()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|user| user.id == user_id)
|
||||
.and_then(|user| user.feature_settings))
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_non_admin_export_users(
|
||||
@@ -1670,13 +1702,26 @@ impl GatewayDataState {
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
let key = BillingModelContextCacheKey::ByGlobalModelName {
|
||||
provider_id: normalize_billing_context_cache_part(provider_id),
|
||||
provider_api_key_id: normalize_optional_billing_context_cache_part(provider_api_key_id),
|
||||
global_model_name: normalize_billing_context_cache_part(global_model_name),
|
||||
};
|
||||
if let Some(value) = self.cached_billing_model_context(&key) {
|
||||
return Ok(value);
|
||||
}
|
||||
match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
let value = repository
|
||||
.find_model_context(provider_id, provider_api_key_id, global_model_name)
|
||||
.await
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
Ok(None)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1686,16 +1731,74 @@ impl GatewayDataState {
|
||||
provider_api_key_id: Option<&str>,
|
||||
model_id: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
let key = BillingModelContextCacheKey::ByModelId {
|
||||
provider_id: normalize_billing_context_cache_part(provider_id),
|
||||
provider_api_key_id: normalize_optional_billing_context_cache_part(provider_api_key_id),
|
||||
model_id: normalize_billing_context_cache_part(model_id),
|
||||
};
|
||||
if let Some(value) = self.cached_billing_model_context(&key) {
|
||||
return Ok(value);
|
||||
}
|
||||
match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
let value = repository
|
||||
.find_model_context_by_model_id(provider_id, provider_api_key_id, model_id)
|
||||
.await
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
Ok(None)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_billing_model_context(
|
||||
&self,
|
||||
key: &BillingModelContextCacheKey,
|
||||
) -> Option<Option<StoredBillingModelContext>> {
|
||||
self.billing_model_context_cache
|
||||
.read()
|
||||
.expect("billing model context cache lock")
|
||||
.get(key)
|
||||
.and_then(|(cached_at, value)| {
|
||||
(cached_at.elapsed() <= Self::BILLING_MODEL_CONTEXT_CACHE_TTL)
|
||||
.then(|| value.clone())
|
||||
})
|
||||
}
|
||||
|
||||
fn remember_billing_model_context(
|
||||
&self,
|
||||
key: BillingModelContextCacheKey,
|
||||
value: Option<StoredBillingModelContext>,
|
||||
) {
|
||||
let mut cache = self
|
||||
.billing_model_context_cache
|
||||
.write()
|
||||
.expect("billing model context cache lock");
|
||||
cache.retain(|_, (cached_at, _)| {
|
||||
cached_at.elapsed() <= Self::BILLING_MODEL_CONTEXT_CACHE_TTL
|
||||
});
|
||||
if cache.len() >= Self::BILLING_MODEL_CONTEXT_CACHE_MAX_ENTRIES {
|
||||
if let Some(oldest_key) = cache
|
||||
.iter()
|
||||
.min_by_key(|(_, (cached_at, _))| *cached_at)
|
||||
.map(|(key, _)| key.clone())
|
||||
{
|
||||
cache.remove(&oldest_key);
|
||||
}
|
||||
}
|
||||
cache.insert(key, (Instant::now(), value));
|
||||
}
|
||||
|
||||
fn clear_billing_model_context_cache(&self) {
|
||||
self.billing_model_context_cache
|
||||
.write()
|
||||
.expect("billing model context cache lock")
|
||||
.clear();
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_billing_enabled_default_value_exists(
|
||||
&self,
|
||||
api_format: &str,
|
||||
@@ -1722,10 +1825,14 @@ impl GatewayDataState {
|
||||
&self,
|
||||
input: &AdminBillingRuleWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<AdminBillingRuleRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
let result = match &self.billing_reader {
|
||||
Some(repository) => repository.create_admin_billing_rule(input).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_billing_rules(
|
||||
@@ -1760,20 +1867,28 @@ impl GatewayDataState {
|
||||
rule_id: &str,
|
||||
input: &AdminBillingRuleWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<AdminBillingRuleRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
let result = match &self.billing_reader {
|
||||
Some(repository) => repository.update_admin_billing_rule(rule_id, input).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn create_admin_billing_collector(
|
||||
&self,
|
||||
input: &AdminBillingCollectorWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<AdminBillingCollectorRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
let result = match &self.billing_reader {
|
||||
Some(repository) => repository.create_admin_billing_collector(input).await,
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn list_admin_billing_collectors(
|
||||
@@ -1817,14 +1932,18 @@ impl GatewayDataState {
|
||||
collector_id: &str,
|
||||
input: &AdminBillingCollectorWriteInput,
|
||||
) -> Result<AdminBillingMutationOutcome<AdminBillingCollectorRecord>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
let result = match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.update_admin_billing_collector(collector_id, input)
|
||||
.await
|
||||
}
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn apply_admin_billing_preset(
|
||||
@@ -1833,14 +1952,18 @@ impl GatewayDataState {
|
||||
mode: &str,
|
||||
collectors: &[AdminBillingCollectorWriteInput],
|
||||
) -> Result<AdminBillingMutationOutcome<AdminBillingPresetApplyResult>, DataLayerError> {
|
||||
match &self.billing_reader {
|
||||
let result = match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.apply_admin_billing_preset(preset, mode, collectors)
|
||||
.await
|
||||
}
|
||||
None => Ok(AdminBillingMutationOutcome::Unavailable),
|
||||
};
|
||||
if result.is_ok() {
|
||||
self.clear_billing_model_context_cache();
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) async fn find_payment_gateway_config(
|
||||
|
||||
@@ -53,6 +53,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +111,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +146,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,6 +199,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -346,6 +352,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -425,6 +433,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -485,6 +495,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -554,6 +566,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +619,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -657,6 +673,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -720,6 +738,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -785,6 +805,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -834,6 +856,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -898,6 +922,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -955,6 +981,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,6 +1041,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,6 +1105,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1138,6 +1170,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1200,6 +1234,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1251,6 +1287,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,6 +1340,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,6 +1405,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1433,6 +1475,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1484,6 +1528,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1540,6 +1586,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1613,6 +1661,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1681,6 +1731,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1733,6 +1785,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1785,6 +1839,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1839,6 +1895,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1891,6 +1949,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1943,6 +2003,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1995,6 +2057,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2055,6 +2119,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2116,6 +2182,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2180,6 +2248,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2250,6 +2320,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2321,6 +2393,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2396,6 +2470,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2478,6 +2554,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2542,6 +2620,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2597,6 +2677,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2648,6 +2730,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2705,6 +2789,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2766,6 +2852,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2828,6 +2916,8 @@ impl GatewayDataState {
|
||||
wallet_writer: Some(wallet_writer),
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2883,6 +2973,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +117,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,6 +174,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,6 +235,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,6 +300,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,6 +374,8 @@ impl GatewayDataState {
|
||||
wallet_writer: None,
|
||||
settlement_writer: None,
|
||||
system_config_values: None,
|
||||
system_config_value_cache: Default::default(),
|
||||
billing_model_context_cache: Default::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,15 +64,27 @@ fn maintenance_pool_pressure_keeps_idle_reserve_for_foreground_work() {
|
||||
};
|
||||
assert!(GatewayDataState::database_pool_summary_under_maintenance_pressure(&pressured));
|
||||
|
||||
let one_idle_left = aether_data::DatabasePoolSummary {
|
||||
let reserve_idle_left = aether_data::DatabasePoolSummary {
|
||||
driver: DatabaseDriver::Postgres,
|
||||
checked_out: 5,
|
||||
pool_size: 6,
|
||||
idle: 1,
|
||||
checked_out: 18,
|
||||
pool_size: 20,
|
||||
idle: 2,
|
||||
max_connections: 20,
|
||||
usage_rate: 25.0,
|
||||
usage_rate: 90.0,
|
||||
};
|
||||
assert!(GatewayDataState::database_pool_summary_under_maintenance_pressure(&one_idle_left));
|
||||
assert!(GatewayDataState::database_pool_summary_under_maintenance_pressure(&reserve_idle_left));
|
||||
|
||||
let above_idle_reserve = aether_data::DatabasePoolSummary {
|
||||
driver: DatabaseDriver::Postgres,
|
||||
checked_out: 17,
|
||||
pool_size: 20,
|
||||
idle: 3,
|
||||
max_connections: 20,
|
||||
usage_rate: 85.0,
|
||||
};
|
||||
assert!(
|
||||
!GatewayDataState::database_pool_summary_under_maintenance_pressure(&above_idle_reserve)
|
||||
);
|
||||
|
||||
let idle = aether_data::DatabasePoolSummary {
|
||||
driver: DatabaseDriver::Postgres,
|
||||
|
||||
@@ -18,7 +18,7 @@ use aether_scheduler_core::{
|
||||
use aether_usage_runtime::{
|
||||
build_lifecycle_usage_seed, build_stream_terminal_usage_payload_seed,
|
||||
build_sync_terminal_usage_payload_seed, build_terminal_usage_context_seed, LifecycleUsageSeed,
|
||||
UsageBodyCapturePolicy, UsageRequestRecordLevel, UsageRuntimeAccess,
|
||||
UsageBodyCapturePolicy, UsageRequestRecordLevel,
|
||||
DEFAULT_USAGE_RESPONSE_BODY_CAPTURE_LIMIT_BYTES,
|
||||
};
|
||||
use async_stream::stream;
|
||||
@@ -2836,7 +2836,9 @@ async fn execute_stream_from_frame_stream(
|
||||
let emit_passthrough_sse_terminal_error = skip_direct_finalize_prefetch
|
||||
&& response_headers_indicate_sse(&upstream_headers)
|
||||
&& !is_openai_image_stream_for_report;
|
||||
let body_capture_policy = match UsageRuntimeAccess::body_capture_policy(state.data.as_ref())
|
||||
let body_capture_policy = match state
|
||||
.usage_runtime
|
||||
.body_capture_policy_for(state.data.as_ref())
|
||||
.await
|
||||
{
|
||||
Ok(policy) => policy,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::error::Error as _;
|
||||
use std::future::Future;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
use std::sync::{LazyLock, Mutex as StdMutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_contracts::{
|
||||
@@ -45,6 +46,18 @@ const DEFAULT_STREAM_FIRST_BYTE_TIMEOUT_MS: u64 = 30_000;
|
||||
const DEFAULT_NON_STREAM_TOTAL_TIMEOUT_MS: u64 = 300_000;
|
||||
const MIN_TUNNEL_TIMEOUT_SECS: u64 = 1;
|
||||
const MAX_TUNNEL_TIMEOUT_SECS: u64 = 300;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
struct DirectReqwestClientCacheKey {
|
||||
connect_timeout_ms: Option<u64>,
|
||||
follow_redirects: bool,
|
||||
http1_only: bool,
|
||||
accept_invalid_certs: bool,
|
||||
}
|
||||
|
||||
static DIRECT_REQWEST_CLIENT_CACHE: LazyLock<
|
||||
StdMutex<HashMap<DirectReqwestClientCacheKey, reqwest::Client>>,
|
||||
> = LazyLock::new(|| StdMutex::new(HashMap::new()));
|
||||
pub(crate) fn format_upstream_request_error(err: &reqwest::Error) -> String {
|
||||
let mut kinds = Vec::new();
|
||||
if err.is_connect() {
|
||||
@@ -1352,6 +1365,28 @@ fn build_client(
|
||||
transport_controls: ExecutionTransportControls,
|
||||
) -> Result<reqwest::Client, ExecutionRuntimeTransportError> {
|
||||
validate_reqwest_transport_profile(transport_profile)?;
|
||||
let resolved_proxy_url = resolve_proxy_url(proxy)?;
|
||||
if resolved_proxy_url.is_none() && transport_profile.is_none() {
|
||||
let cache_key = DirectReqwestClientCacheKey {
|
||||
connect_timeout_ms: timeouts.and_then(|timeouts| timeouts.connect_ms),
|
||||
follow_redirects: transport_controls.follow_redirects == Some(true),
|
||||
http1_only: transport_controls.http1_only,
|
||||
accept_invalid_certs: transport_controls.accept_invalid_certs,
|
||||
};
|
||||
if let Ok(cache) = DIRECT_REQWEST_CLIENT_CACHE.lock() {
|
||||
if let Some(client) = cache.get(&cache_key) {
|
||||
return Ok(client.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let client = build_plain_direct_reqwest_client(cache_key)?;
|
||||
if let Ok(mut cache) = DIRECT_REQWEST_CLIENT_CACHE.lock() {
|
||||
let client = cache.entry(cache_key).or_insert_with(|| client.clone());
|
||||
return Ok(client.clone());
|
||||
}
|
||||
return Ok(client);
|
||||
}
|
||||
|
||||
let mut builder = reqwest::Client::builder();
|
||||
if transport_controls.follow_redirects != Some(true) {
|
||||
builder = builder.redirect(Policy::none());
|
||||
@@ -1370,7 +1405,7 @@ fn build_client(
|
||||
if transport_controls.accept_invalid_certs {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
if let Some(proxy_url) = resolve_proxy_url(proxy)? {
|
||||
if let Some(proxy_url) = resolved_proxy_url {
|
||||
let proxy = reqwest::Proxy::all(&proxy_url)
|
||||
.map_err(ExecutionRuntimeTransportError::InvalidProxy)?;
|
||||
builder = builder.proxy(proxy);
|
||||
@@ -1380,6 +1415,41 @@ fn build_client(
|
||||
.map_err(ExecutionRuntimeTransportError::ClientBuild)
|
||||
}
|
||||
|
||||
fn build_plain_direct_reqwest_client(
|
||||
cache_key: DirectReqwestClientCacheKey,
|
||||
) -> Result<reqwest::Client, ExecutionRuntimeTransportError> {
|
||||
let mut builder = reqwest::Client::builder();
|
||||
if !cache_key.follow_redirects {
|
||||
builder = builder.redirect(Policy::none());
|
||||
}
|
||||
if cache_key.http1_only {
|
||||
builder = builder.http1_only();
|
||||
}
|
||||
let mut builder = apply_http_client_config(
|
||||
builder,
|
||||
&HttpClientConfig {
|
||||
connect_timeout_ms: cache_key.connect_timeout_ms,
|
||||
pool_max_idle_per_host: Some(direct_reqwest_pool_max_idle_per_host()),
|
||||
..HttpClientConfig::default()
|
||||
},
|
||||
);
|
||||
if cache_key.accept_invalid_certs {
|
||||
builder = builder.danger_accept_invalid_certs(true);
|
||||
}
|
||||
builder
|
||||
.build()
|
||||
.map_err(ExecutionRuntimeTransportError::ClientBuild)
|
||||
}
|
||||
|
||||
fn direct_reqwest_pool_max_idle_per_host() -> usize {
|
||||
const DEFAULT_MAX_IDLE_PER_HOST: usize = 1024;
|
||||
std::env::var("AETHER_GATEWAY_UPSTREAM_POOL_MAX_IDLE_PER_HOST")
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<usize>().ok())
|
||||
.filter(|value| *value > 0)
|
||||
.unwrap_or(DEFAULT_MAX_IDLE_PER_HOST)
|
||||
}
|
||||
|
||||
pub(crate) fn build_browser_wreq_client(
|
||||
timeouts: Option<&aether_contracts::ExecutionTimeouts>,
|
||||
proxy: Option<&ProxySnapshot>,
|
||||
|
||||
@@ -494,6 +494,20 @@ impl GatewayDataArgs {
|
||||
|
||||
#[derive(ClapArgs, Debug, Clone)]
|
||||
struct GatewayUsageArgs {
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_USAGE_QUEUE_TERMINAL_EVENTS",
|
||||
default_value_t = true
|
||||
)]
|
||||
queue_terminal_events: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_USAGE_QUEUE_LIFECYCLE_EVENTS",
|
||||
default_value_t = true
|
||||
)]
|
||||
queue_lifecycle_events: bool,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_USAGE_QUEUE_STREAM_KEY",
|
||||
@@ -518,14 +532,14 @@ struct GatewayUsageArgs {
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_USAGE_QUEUE_STREAM_MAXLEN",
|
||||
default_value_t = 2_000
|
||||
default_value_t = 200_000
|
||||
)]
|
||||
queue_stream_maxlen: usize,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_USAGE_QUEUE_BATCH_SIZE",
|
||||
default_value_t = 200
|
||||
default_value_t = 500
|
||||
)]
|
||||
queue_batch_size: usize,
|
||||
|
||||
@@ -546,7 +560,7 @@ struct GatewayUsageArgs {
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_COUNT",
|
||||
default_value_t = 200
|
||||
default_value_t = 500
|
||||
)]
|
||||
queue_reclaim_count: usize,
|
||||
|
||||
@@ -562,7 +576,8 @@ impl GatewayUsageArgs {
|
||||
fn to_config(&self) -> UsageRuntimeConfig {
|
||||
UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
queue_terminal_events: true,
|
||||
queue_terminal_events: self.queue_terminal_events,
|
||||
queue_lifecycle_events: self.queue_lifecycle_events,
|
||||
stream_key: self.queue_stream_key.trim().to_string(),
|
||||
consumer_group: self.queue_group.trim().to_string(),
|
||||
dlq_stream_key: self.queue_dlq_stream_key.trim().to_string(),
|
||||
@@ -853,6 +868,13 @@ struct Args {
|
||||
#[arg(long, env = "AETHER_RUNTIME_REDIS_KEY_PREFIX")]
|
||||
runtime_redis_key_prefix: Option<String>,
|
||||
|
||||
#[arg(
|
||||
long,
|
||||
env = "AETHER_RUNTIME_COMMAND_TIMEOUT_MS",
|
||||
default_value_t = 1_000
|
||||
)]
|
||||
runtime_command_timeout_ms: u64,
|
||||
|
||||
#[command(flatten)]
|
||||
data: GatewayDataArgs,
|
||||
|
||||
@@ -932,6 +954,7 @@ impl Args {
|
||||
RuntimeStateConfig {
|
||||
backend: runtime_backend.to_runtime_state_backend(),
|
||||
redis,
|
||||
command_timeout_ms: Some(self.runtime_command_timeout_ms.max(1)),
|
||||
..RuntimeStateConfig::default()
|
||||
}
|
||||
}
|
||||
@@ -1738,6 +1761,7 @@ mod tests {
|
||||
runtime_backend: None,
|
||||
runtime_redis_url: None,
|
||||
runtime_redis_key_prefix: None,
|
||||
runtime_command_timeout_ms: 1_000,
|
||||
data: GatewayDataArgs {
|
||||
database_driver: None,
|
||||
database_url: None,
|
||||
@@ -1754,14 +1778,16 @@ mod tests {
|
||||
postgres_require_ssl: false,
|
||||
},
|
||||
usage: GatewayUsageArgs {
|
||||
queue_terminal_events: true,
|
||||
queue_lifecycle_events: true,
|
||||
queue_stream_key: "usage:events".to_string(),
|
||||
queue_group: "usage_consumers".to_string(),
|
||||
queue_dlq_stream_key: "usage:events:dlq".to_string(),
|
||||
queue_stream_maxlen: 2_000,
|
||||
queue_batch_size: 200,
|
||||
queue_stream_maxlen: 200_000,
|
||||
queue_batch_size: 500,
|
||||
queue_block_ms: 500,
|
||||
queue_reclaim_idle_ms: 30_000,
|
||||
queue_reclaim_count: 200,
|
||||
queue_reclaim_count: 500,
|
||||
queue_reclaim_interval_ms: 5_000,
|
||||
},
|
||||
frontdoor: GatewayFrontdoorArgs {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::Utc;
|
||||
use tracing::{debug, warn};
|
||||
@@ -31,6 +31,7 @@ use super::{
|
||||
|
||||
const STATS_DAILY_CATCH_UP_BURST_LIMIT: usize = 14;
|
||||
const STATS_HOURLY_CATCH_UP_BURST_LIMIT: usize = 72;
|
||||
const MAINTENANCE_PRESSURE_RETRY_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
fn log_maintenance_worker_failure(
|
||||
worker: &'static str,
|
||||
@@ -71,6 +72,7 @@ fn should_defer_for_database_pressure(
|
||||
checked_out = summary.checked_out,
|
||||
pool_size = summary.pool_size,
|
||||
idle = summary.idle,
|
||||
idle_reserve = GatewayDataState::maintenance_pool_idle_reserve(&summary),
|
||||
max_connections = summary.max_connections,
|
||||
usage_rate = summary.usage_rate,
|
||||
"gateway maintenance worker deferred because database pool has no idle reserve"
|
||||
@@ -92,8 +94,12 @@ pub(crate) fn spawn_audit_cleanup_worker(
|
||||
let mut interval = tokio::time::interval(AUDIT_LOG_CLEANUP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
interval.tick().await;
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if should_defer_for_database_pressure(&data, "audit_cleanup", &mut deferred_since) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = run_audit_cleanup_once(&data).await {
|
||||
log_maintenance_worker_failure("audit_cleanup", "tick", &err);
|
||||
}
|
||||
@@ -110,8 +116,17 @@ pub(crate) fn spawn_db_maintenance_worker(
|
||||
|
||||
let timezone = maintenance_timezone();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
tokio::time::sleep(duration_until_next_db_maintenance_run(Utc::now(), timezone)).await;
|
||||
loop {
|
||||
if should_defer_for_database_pressure(&data, "db_maintenance", &mut deferred_since)
|
||||
{
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(err) = run_db_maintenance_once(&data).await {
|
||||
log_maintenance_worker_failure("db_maintenance", "tick", &err);
|
||||
}
|
||||
@@ -128,6 +143,7 @@ pub(crate) fn spawn_wallet_daily_usage_aggregation_worker(
|
||||
|
||||
let timezone = maintenance_timezone();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
tokio::time::sleep(duration_until_next_daily_run(
|
||||
Utc::now(),
|
||||
@@ -136,6 +152,17 @@ pub(crate) fn spawn_wallet_daily_usage_aggregation_worker(
|
||||
WALLET_DAILY_USAGE_AGGREGATION_MINUTE,
|
||||
))
|
||||
.await;
|
||||
loop {
|
||||
if should_defer_for_database_pressure(
|
||||
&data,
|
||||
"wallet_daily_usage_aggregation",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(err) = run_wallet_daily_usage_aggregation_once(&data).await {
|
||||
log_maintenance_worker_failure("wallet_daily_usage_aggregation", "tick", &err);
|
||||
}
|
||||
@@ -151,9 +178,19 @@ pub(crate) fn spawn_stats_aggregation_worker(
|
||||
}
|
||||
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
let mut processed = 0_usize;
|
||||
let mut deferred = false;
|
||||
while processed < STATS_DAILY_CATCH_UP_BURST_LIMIT {
|
||||
if should_defer_for_database_pressure(
|
||||
&data,
|
||||
"stats_daily_aggregation",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
deferred = true;
|
||||
break;
|
||||
}
|
||||
match run_stats_aggregation_once(&data).await {
|
||||
Ok(true) => processed += 1,
|
||||
Ok(false) => break,
|
||||
@@ -164,6 +201,11 @@ pub(crate) fn spawn_stats_aggregation_worker(
|
||||
}
|
||||
}
|
||||
|
||||
if deferred {
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if processed >= STATS_DAILY_CATCH_UP_BURST_LIMIT {
|
||||
continue;
|
||||
}
|
||||
@@ -182,6 +224,7 @@ pub(crate) fn spawn_usage_cleanup_worker(
|
||||
|
||||
let timezone = maintenance_timezone();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
tokio::time::sleep(duration_until_next_daily_run(
|
||||
Utc::now(),
|
||||
@@ -190,6 +233,13 @@ pub(crate) fn spawn_usage_cleanup_worker(
|
||||
USAGE_CLEANUP_MINUTE,
|
||||
))
|
||||
.await;
|
||||
loop {
|
||||
if should_defer_for_database_pressure(&data, "usage_cleanup", &mut deferred_since) {
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(err) = run_usage_cleanup_once(&data).await {
|
||||
log_maintenance_worker_failure("usage_cleanup", "tick", &err);
|
||||
}
|
||||
@@ -277,6 +327,7 @@ pub(crate) fn spawn_provider_checkin_worker(
|
||||
|
||||
let timezone = maintenance_timezone();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
let (hour, minute) = match provider_checkin_schedule(&state.data).await {
|
||||
Ok(schedule) => schedule,
|
||||
@@ -301,6 +352,17 @@ pub(crate) fn spawn_provider_checkin_worker(
|
||||
minute,
|
||||
))
|
||||
.await;
|
||||
loop {
|
||||
if should_defer_for_database_pressure(
|
||||
&state.data,
|
||||
"provider_checkin",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(err) = run_provider_checkin_once(&state).await {
|
||||
log_maintenance_worker_failure("provider_checkin", "tick", &err);
|
||||
}
|
||||
@@ -381,8 +443,16 @@ pub(crate) fn spawn_gemini_file_mapping_cleanup_worker(
|
||||
let mut interval = tokio::time::interval(GEMINI_FILE_MAPPING_CLEANUP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
interval.tick().await;
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if should_defer_for_database_pressure(
|
||||
&data,
|
||||
"gemini_file_mapping_cleanup",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = run_gemini_file_mapping_cleanup_once(&data).await {
|
||||
log_maintenance_worker_failure("gemini_file_mapping_cleanup", "tick", &err);
|
||||
}
|
||||
@@ -457,6 +527,7 @@ pub(crate) fn spawn_proxy_node_metrics_cleanup_worker(
|
||||
|
||||
let timezone = maintenance_timezone();
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
tokio::time::sleep(duration_until_next_daily_run(
|
||||
Utc::now(),
|
||||
@@ -465,6 +536,17 @@ pub(crate) fn spawn_proxy_node_metrics_cleanup_worker(
|
||||
PROXY_NODE_METRICS_CLEANUP_MINUTE,
|
||||
))
|
||||
.await;
|
||||
loop {
|
||||
if should_defer_for_database_pressure(
|
||||
&data,
|
||||
"proxy_node_metrics_cleanup",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if let Err(err) = run_proxy_node_metrics_cleanup_once(&data).await {
|
||||
log_maintenance_worker_failure("proxy_node_metrics_cleanup", "tick", &err);
|
||||
}
|
||||
@@ -532,9 +614,19 @@ pub(crate) fn spawn_stats_hourly_aggregation_worker(
|
||||
}
|
||||
|
||||
Some(tokio::spawn(async move {
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
let mut processed = 0_usize;
|
||||
let mut deferred = false;
|
||||
while processed < STATS_HOURLY_CATCH_UP_BURST_LIMIT {
|
||||
if should_defer_for_database_pressure(
|
||||
&data,
|
||||
"stats_hourly_aggregation",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
deferred = true;
|
||||
break;
|
||||
}
|
||||
match run_stats_hourly_aggregation_once(&data).await {
|
||||
Ok(true) => processed += 1,
|
||||
Ok(false) => break,
|
||||
@@ -545,6 +637,11 @@ pub(crate) fn spawn_stats_hourly_aggregation_worker(
|
||||
}
|
||||
}
|
||||
|
||||
if deferred {
|
||||
tokio::time::sleep(MAINTENANCE_PRESSURE_RETRY_INTERVAL).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if processed >= STATS_HOURLY_CATCH_UP_BURST_LIMIT {
|
||||
continue;
|
||||
}
|
||||
@@ -568,8 +665,16 @@ pub(crate) fn spawn_request_candidate_cleanup_worker(
|
||||
let mut interval = tokio::time::interval(REQUEST_CANDIDATE_CLEANUP_INTERVAL);
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
interval.tick().await;
|
||||
let mut deferred_since = None;
|
||||
loop {
|
||||
interval.tick().await;
|
||||
if should_defer_for_database_pressure(
|
||||
&data,
|
||||
"request_candidate_cleanup",
|
||||
&mut deferred_since,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if let Err(err) = run_request_candidate_cleanup_once(&data).await {
|
||||
log_maintenance_worker_failure("request_candidate_cleanup", "tick", &err);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::LazyLock;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_admin::provider::quota as admin_provider_quota_pure;
|
||||
use aether_cache::ExpiringMap;
|
||||
use aether_contracts::{ExecutionPlan, ExecutionTelemetry};
|
||||
use aether_data_contracts::repository::pool_scores::{
|
||||
PoolMemberHardState, PoolMemberIdentity, PoolMemberScheduleFeedback,
|
||||
@@ -37,6 +40,30 @@ use crate::scheduler::affinity::SCHEDULER_AFFINITY_TTL;
|
||||
use crate::scheduler::config::{read_scheduler_ordering_config, SchedulerSchedulingMode};
|
||||
use crate::AppState;
|
||||
|
||||
const POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES: usize = 50_000;
|
||||
const POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_ENV: &str =
|
||||
"AETHER_GATEWAY_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS";
|
||||
const POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_ENV: &str =
|
||||
"AETHER_GATEWAY_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS";
|
||||
const DEFAULT_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS: u64 = 5;
|
||||
const DEFAULT_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS: u64 = 1;
|
||||
const MAX_POOL_SCORE_FEEDBACK_MIN_INTERVAL_SECS: u64 = 300;
|
||||
|
||||
static POOL_SCORE_FEEDBACK_GATE: LazyLock<ExpiringMap<String, ()>> =
|
||||
LazyLock::new(ExpiringMap::new);
|
||||
static POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
||||
pool_score_feedback_interval_from_env(
|
||||
POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_ENV,
|
||||
DEFAULT_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS,
|
||||
)
|
||||
});
|
||||
static POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
|
||||
pool_score_feedback_interval_from_env(
|
||||
POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_ENV,
|
||||
DEFAULT_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS,
|
||||
)
|
||||
});
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct LocalExecutionEffectContext<'a> {
|
||||
pub(crate) plan: &'a ExecutionPlan,
|
||||
@@ -518,6 +545,14 @@ async fn record_adaptive_success_effect(
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if current_key.rpm_limit.is_some()
|
||||
|| current_key
|
||||
.learned_rpm_limit
|
||||
.filter(|value| *value > 0)
|
||||
.is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(recent_candidates) = state
|
||||
.read_recent_request_candidates(ADAPTIVE_RPM_RECENT_CANDIDATE_LIMIT)
|
||||
.await
|
||||
@@ -960,6 +995,9 @@ async fn record_pool_score_schedule_feedback(
|
||||
if context.plan.provider_id.trim().is_empty() || context.plan.key_id.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if !pool_score_feedback_gate_allows(context.plan, succeeded, hard_state, score_delta) {
|
||||
return;
|
||||
}
|
||||
let feedback = PoolMemberScheduleFeedback {
|
||||
identity: PoolMemberIdentity::provider_api_key(
|
||||
context.plan.provider_id.clone(),
|
||||
@@ -986,6 +1024,64 @@ async fn record_pool_score_schedule_feedback(
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_score_feedback_interval_from_env(key: &str, default_secs: u64) -> Duration {
|
||||
let secs = std::env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| value.trim().parse::<u64>().ok())
|
||||
.unwrap_or(default_secs)
|
||||
.min(MAX_POOL_SCORE_FEEDBACK_MIN_INTERVAL_SECS);
|
||||
Duration::from_secs(secs)
|
||||
}
|
||||
|
||||
fn pool_score_feedback_min_interval(succeeded: Option<bool>) -> Duration {
|
||||
match succeeded {
|
||||
Some(false) => *POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL,
|
||||
_ => *POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL,
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_score_feedback_gate_key(
|
||||
plan: &ExecutionPlan,
|
||||
succeeded: Option<bool>,
|
||||
hard_state: Option<PoolMemberHardState>,
|
||||
score_delta: Option<i32>,
|
||||
) -> String {
|
||||
let succeeded = match succeeded {
|
||||
Some(true) => "success",
|
||||
Some(false) => "failure",
|
||||
None => "neutral",
|
||||
};
|
||||
let hard_state = hard_state
|
||||
.map(PoolMemberHardState::as_database)
|
||||
.unwrap_or("none");
|
||||
format!(
|
||||
"provider:{}:key:{}:result:{}:state:{}:delta:{}",
|
||||
plan.provider_id,
|
||||
plan.key_id,
|
||||
succeeded,
|
||||
hard_state,
|
||||
score_delta.unwrap_or_default()
|
||||
)
|
||||
}
|
||||
|
||||
fn pool_score_feedback_gate_allows(
|
||||
plan: &ExecutionPlan,
|
||||
succeeded: Option<bool>,
|
||||
hard_state: Option<PoolMemberHardState>,
|
||||
score_delta: Option<i32>,
|
||||
) -> bool {
|
||||
let min_interval = pool_score_feedback_min_interval(succeeded);
|
||||
if min_interval.is_zero() {
|
||||
return true;
|
||||
}
|
||||
let key = pool_score_feedback_gate_key(plan, succeeded, hard_state, score_delta);
|
||||
if POOL_SCORE_FEEDBACK_GATE.contains_fresh(&key, min_interval) {
|
||||
return false;
|
||||
}
|
||||
POOL_SCORE_FEEDBACK_GATE.insert(key, (), min_interval, POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES);
|
||||
true
|
||||
}
|
||||
|
||||
fn pool_score_hard_state_for_status(
|
||||
status_code: u16,
|
||||
error_body: Option<&str>,
|
||||
@@ -1057,10 +1153,10 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
apply_local_execution_effect, local_candidate_failure_should_record_pool_error,
|
||||
pool_score_hard_state_for_status, LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect,
|
||||
LocalAttemptFailureEffect, LocalExecutionEffect, LocalExecutionEffectContext,
|
||||
LocalHealthFailureEffect, LocalHealthSuccessEffect, LocalOAuthInvalidationEffect,
|
||||
LocalPoolErrorEffect,
|
||||
pool_score_feedback_gate_allows, pool_score_hard_state_for_status,
|
||||
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
|
||||
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
|
||||
LocalHealthSuccessEffect, LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
|
||||
};
|
||||
use crate::data::{GatewayDataConfig, GatewayDataState};
|
||||
use crate::orchestration::LocalFailoverClassification;
|
||||
@@ -1107,6 +1203,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pool_score_feedback_gate_suppresses_repeated_success_writes() {
|
||||
super::POOL_SCORE_FEEDBACK_GATE.clear();
|
||||
let plan = sample_plan();
|
||||
|
||||
assert!(pool_score_feedback_gate_allows(
|
||||
&plan,
|
||||
Some(true),
|
||||
Some(PoolMemberHardState::Available),
|
||||
Some(50),
|
||||
));
|
||||
assert!(!pool_score_feedback_gate_allows(
|
||||
&plan,
|
||||
Some(true),
|
||||
Some(PoolMemberHardState::Available),
|
||||
Some(50),
|
||||
));
|
||||
assert!(pool_score_feedback_gate_allows(
|
||||
&plan,
|
||||
Some(false),
|
||||
Some(PoolMemberHardState::Cooldown),
|
||||
Some(-500),
|
||||
));
|
||||
}
|
||||
|
||||
fn session_affinity() -> ClientSessionAffinity {
|
||||
ClientSessionAffinity::new(
|
||||
Some("generic".to_string()),
|
||||
@@ -2075,7 +2196,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn oauth_invalidation_ignores_generic_codex_403() {
|
||||
async fn oauth_invalidation_marks_generic_codex_403_as_token_invalid() {
|
||||
let state = codex_state();
|
||||
let plan = sample_codex_plan();
|
||||
|
||||
@@ -2099,8 +2220,20 @@ mod tests {
|
||||
.into_iter()
|
||||
.next()
|
||||
.expect("stored key should exist");
|
||||
assert_eq!(stored_key.oauth_invalid_at_unix_secs, None);
|
||||
assert_eq!(stored_key.oauth_invalid_reason, None);
|
||||
assert!(stored_key.oauth_invalid_at_unix_secs.is_some());
|
||||
assert_eq!(
|
||||
stored_key.oauth_invalid_reason.as_deref(),
|
||||
Some("[OAUTH_EXPIRED] Codex Token 已失效 (403): forbidden")
|
||||
);
|
||||
assert_eq!(
|
||||
stored_key
|
||||
.status_snapshot
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("oauth"))
|
||||
.and_then(|value| value.get("code"))
|
||||
.and_then(Value::as_str),
|
||||
Some("invalid")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -14,6 +14,7 @@ use aether_scheduler_core::{
|
||||
use aether_usage_runtime::build_locally_actionable_report_context_from_request_candidate;
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use std::sync::OnceLock;
|
||||
use tracing::{debug, info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -21,6 +22,51 @@ use crate::clock::current_unix_ms;
|
||||
use crate::log_ids::short_request_id;
|
||||
use crate::GatewayError;
|
||||
|
||||
const REQUEST_CANDIDATE_PERSISTENCE_ENV: &str = "AETHER_GATEWAY_REQUEST_CANDIDATE_PERSISTENCE";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum RequestCandidatePersistenceMode {
|
||||
Full,
|
||||
Terminal,
|
||||
None,
|
||||
}
|
||||
|
||||
fn request_candidate_persistence_mode() -> RequestCandidatePersistenceMode {
|
||||
static MODE: OnceLock<RequestCandidatePersistenceMode> = OnceLock::new();
|
||||
*MODE.get_or_init(|| {
|
||||
match std::env::var(REQUEST_CANDIDATE_PERSISTENCE_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("terminal") | Some("final") | Some("final_only") | Some("final-only") => {
|
||||
RequestCandidatePersistenceMode::Terminal
|
||||
}
|
||||
Some("none") | Some("off") | Some("disabled") | Some("false") | Some("0") => {
|
||||
RequestCandidatePersistenceMode::None
|
||||
}
|
||||
_ => RequestCandidatePersistenceMode::Full,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn request_candidate_status_is_terminal(status: RequestCandidateStatus) -> bool {
|
||||
matches!(
|
||||
status,
|
||||
RequestCandidateStatus::Success
|
||||
| RequestCandidateStatus::Failed
|
||||
| RequestCandidateStatus::Cancelled
|
||||
)
|
||||
}
|
||||
|
||||
fn should_persist_request_candidate_status(status: RequestCandidateStatus) -> bool {
|
||||
match request_candidate_persistence_mode() {
|
||||
RequestCandidatePersistenceMode::Full => true,
|
||||
RequestCandidatePersistenceMode::Terminal => request_candidate_status_is_terminal(status),
|
||||
RequestCandidatePersistenceMode::None => false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct LocalRequestCandidateStatusSnapshot {
|
||||
candidate_id: String,
|
||||
@@ -226,6 +272,21 @@ async fn persist_local_request_candidate_status_record(
|
||||
let retry_index = record.retry_index;
|
||||
let status = record.status;
|
||||
|
||||
if !should_persist_request_candidate_status(status) {
|
||||
debug!(
|
||||
event_name = "request_candidate_status_persistence_skipped",
|
||||
log_type = "event",
|
||||
request_id = %request_id,
|
||||
candidate_id = %candidate_id,
|
||||
candidate_index,
|
||||
retry_index,
|
||||
status = request_candidate_status_label(status),
|
||||
source = "local_status",
|
||||
"gateway skipped request candidate status update due to persistence mode"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
match state.upsert_request_candidate(record).await {
|
||||
Ok(Some(stored)) => {
|
||||
debug!(
|
||||
@@ -373,6 +434,12 @@ pub(crate) async fn record_report_request_candidate_status(
|
||||
report_context: Option<&Value>,
|
||||
status_update: SchedulerRequestCandidateStatusUpdate,
|
||||
) {
|
||||
if matches!(
|
||||
request_candidate_persistence_mode(),
|
||||
RequestCandidatePersistenceMode::None
|
||||
) {
|
||||
return;
|
||||
}
|
||||
let Some(slot) = resolve_report_request_candidate_slot(state, report_context).await else {
|
||||
return;
|
||||
};
|
||||
@@ -389,6 +456,21 @@ pub(crate) async fn record_report_request_candidate_status(
|
||||
let candidate_id = record.id.clone();
|
||||
let status = record.status;
|
||||
|
||||
if !should_persist_request_candidate_status(status) {
|
||||
debug!(
|
||||
event_name = "request_candidate_report_status_persistence_skipped",
|
||||
log_type = "event",
|
||||
request_id = %request_id_for_log,
|
||||
candidate_id = %candidate_id,
|
||||
candidate_index,
|
||||
retry_index,
|
||||
status = request_candidate_status_label(status),
|
||||
source = "report_status",
|
||||
"gateway skipped report-driven request candidate status update due to persistence mode"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
match state.upsert_request_candidate(record).await {
|
||||
Ok(Some(stored)) => {
|
||||
debug!(
|
||||
@@ -471,6 +553,26 @@ pub(crate) async fn ensure_execution_request_candidate_slot(
|
||||
let generated_candidate_id = seed.upsert_record.id.clone();
|
||||
let request_id = short_request_id(plan.request_id.as_str());
|
||||
|
||||
if !should_persist_request_candidate_status(seed.upsert_record.status) {
|
||||
plan.candidate_id = Some(generated_candidate_id.clone());
|
||||
*report_context = Some(finalize_execution_request_candidate_report_context(
|
||||
seed.report_context,
|
||||
&generated_candidate_id,
|
||||
));
|
||||
debug!(
|
||||
event_name = "request_candidate_slot_seed_persistence_skipped",
|
||||
log_type = "event",
|
||||
request_id = %request_id,
|
||||
candidate_id = %generated_candidate_id,
|
||||
provider_id = %plan.provider_id,
|
||||
endpoint_id = %plan.endpoint_id,
|
||||
key_id = %plan.key_id,
|
||||
source = "seed",
|
||||
"gateway skipped request candidate seed due to persistence mode"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let candidate_id = match state.upsert_request_candidate(seed.upsert_record).await {
|
||||
Ok(Some(stored)) => {
|
||||
info!(
|
||||
@@ -533,6 +635,9 @@ pub(crate) async fn persist_available_local_candidate(
|
||||
created_at_unix_ms: u64,
|
||||
error_context: &'static str,
|
||||
) -> String {
|
||||
if !should_persist_request_candidate_status(RequestCandidateStatus::Available) {
|
||||
return candidate_id.to_string();
|
||||
}
|
||||
match state
|
||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
||||
id: candidate_id.to_string(),
|
||||
@@ -624,6 +729,9 @@ pub(crate) async fn persist_skipped_local_candidate(
|
||||
finished_at_unix_ms: u64,
|
||||
error_context: &'static str,
|
||||
) {
|
||||
if !should_persist_request_candidate_status(RequestCandidateStatus::Skipped) {
|
||||
return;
|
||||
}
|
||||
match state
|
||||
.upsert_request_candidate(UpsertRequestCandidateRecord {
|
||||
id: candidate_id.to_string(),
|
||||
@@ -725,6 +833,25 @@ async fn resolve_report_request_candidate_slot(
|
||||
report_context: Option<&Value>,
|
||||
) -> Option<SchedulerResolvedReportRequestCandidateSlot> {
|
||||
let metadata = parse_request_candidate_report_context(report_context)?;
|
||||
if metadata
|
||||
.request_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
&& metadata
|
||||
.candidate_id
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.is_some_and(|value| !value.is_empty())
|
||||
{
|
||||
return resolve_report_request_candidate_slot_from_candidates(
|
||||
&[],
|
||||
metadata,
|
||||
current_unix_ms(),
|
||||
Uuid::new_v4().to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let request_id = metadata.request_id.clone()?;
|
||||
let existing_candidates = state
|
||||
.read_request_candidates_by_request_id(request_id.as_str())
|
||||
|
||||
@@ -8,7 +8,7 @@ use aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKe
|
||||
use aether_scheduler_core::{
|
||||
auth_api_key_concurrency_limit_reached, build_provider_concurrent_limit_map,
|
||||
candidate_is_selectable_with_runtime_state, candidate_runtime_skip_reason_with_state,
|
||||
CandidateRuntimeSelectabilityInput,
|
||||
effective_provider_key_rpm_limit, CandidateRuntimeSelectabilityInput,
|
||||
};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -33,9 +33,9 @@ pub(super) struct CandidateRuntimeSelectionSnapshot {
|
||||
pub(super) async fn read_candidate_runtime_selection_snapshot(
|
||||
state: &(impl SchedulerRuntimeState + ?Sized),
|
||||
candidates: &[SchedulerMinimalCandidateSelectionCandidate],
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<CandidateRuntimeSelectionSnapshot, GatewayError> {
|
||||
let recent_candidates = state.read_recent_request_candidates(128).await?;
|
||||
let provider_concurrent_limits = read_provider_concurrent_limits(state, candidates).await?;
|
||||
let provider_pool_state = read_provider_pool_state_map(state, candidates).await?;
|
||||
let provider_skip_exhausted_accounts = provider_pool_state
|
||||
@@ -47,6 +47,16 @@ pub(super) async fn read_candidate_runtime_selection_snapshot(
|
||||
.filter_map(|(provider_id, state)| state.pool_enabled.then_some(provider_id.clone()))
|
||||
.collect::<BTreeSet<_>>();
|
||||
let provider_key_rpm_states = read_provider_key_rpm_states(state, candidates).await?;
|
||||
let recent_candidates = if runtime_snapshot_requires_recent_candidates(
|
||||
auth_snapshot,
|
||||
&provider_concurrent_limits,
|
||||
&provider_key_rpm_states,
|
||||
now_unix_secs,
|
||||
) {
|
||||
state.read_recent_request_candidates(128).await?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let key_account_quota_exhausted = read_key_account_quota_exhaustion_map(
|
||||
candidates,
|
||||
&provider_key_rpm_states,
|
||||
@@ -71,6 +81,29 @@ pub(super) async fn read_candidate_runtime_selection_snapshot(
|
||||
})
|
||||
}
|
||||
|
||||
fn runtime_snapshot_requires_recent_candidates(
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
provider_concurrent_limits: &BTreeMap<String, usize>,
|
||||
provider_key_rpm_states: &BTreeMap<String, StoredProviderCatalogKey>,
|
||||
now_unix_secs: u64,
|
||||
) -> bool {
|
||||
if auth_snapshot
|
||||
.and_then(|snapshot| snapshot.api_key_concurrent_limit)
|
||||
.is_some_and(|limit| limit > 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if provider_concurrent_limits.values().any(|limit| *limit > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
provider_key_rpm_states.values().any(|key| {
|
||||
key.concurrent_limit.is_some_and(|limit| limit > 0)
|
||||
|| effective_provider_key_rpm_limit(key, now_unix_secs).is_some()
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn auth_snapshot_concurrency_limit_reached(
|
||||
auth_snapshot: Option<&GatewayAuthApiKeySnapshot>,
|
||||
snapshot: &CandidateRuntimeSelectionSnapshot,
|
||||
|
||||
@@ -200,9 +200,13 @@ pub(super) async fn collect_selectable_enumerated_candidates_with_skip_reasons(
|
||||
),
|
||||
GatewayError,
|
||||
> {
|
||||
let runtime_snapshot =
|
||||
read_candidate_runtime_selection_snapshot(runtime_state, &candidates, now_unix_secs)
|
||||
.await?;
|
||||
let runtime_snapshot = read_candidate_runtime_selection_snapshot(
|
||||
runtime_state,
|
||||
&candidates,
|
||||
auth_snapshot,
|
||||
now_unix_secs,
|
||||
)
|
||||
.await?;
|
||||
let affinity_cache_key = build_scheduler_affinity_cache_key(
|
||||
auth_snapshot,
|
||||
api_format,
|
||||
|
||||
@@ -4,13 +4,16 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data::repository::users::StoredUserGroup;
|
||||
use aether_data_contracts::repository::quota::StoredProviderQuotaSnapshot;
|
||||
use aether_runtime::ConcurrencyGate;
|
||||
use aether_runtime_state::{RuntimeSemaphore, RuntimeState};
|
||||
|
||||
use super::super::async_task::{VideoTaskPollerConfig, VideoTaskService};
|
||||
use super::super::cache::{
|
||||
AuthApiKeyLastUsedCache, AuthContextCache, DashboardResponseCache, DirectPlanBypassCache,
|
||||
SchedulerAffinityCache, SystemConfigCache,
|
||||
AuthApiKeyFeatureCacheKey, AuthApiKeyIdentityCacheKey, AuthApiKeyLastUsedCache,
|
||||
AuthContextCache, AuthSnapshotCache, DashboardResponseCache, DirectPlanBypassCache,
|
||||
JsonValueCache, SchedulerAffinityCache, SystemConfigCache, ValueCache,
|
||||
};
|
||||
use super::super::data::GatewayDataState;
|
||||
use super::super::fallback_metrics;
|
||||
@@ -116,6 +119,14 @@ pub struct AppState {
|
||||
pub(crate) distributed_request_gate: Option<Arc<RuntimeSemaphore>>,
|
||||
pub(crate) client: reqwest::Client,
|
||||
pub(crate) auth_context_cache: Arc<AuthContextCache>,
|
||||
pub(crate) auth_snapshot_cache: Arc<AuthSnapshotCache>,
|
||||
pub(crate) user_model_capability_settings_cache: Arc<JsonValueCache<String>>,
|
||||
pub(crate) user_feature_settings_cache: Arc<JsonValueCache<String>>,
|
||||
pub(crate) auth_api_key_force_capabilities_cache:
|
||||
Arc<JsonValueCache<AuthApiKeyIdentityCacheKey>>,
|
||||
pub(crate) auth_api_key_feature_settings_cache: Arc<JsonValueCache<AuthApiKeyFeatureCacheKey>>,
|
||||
pub(crate) provider_quota_snapshot_cache: Arc<ValueCache<String, StoredProviderQuotaSnapshot>>,
|
||||
pub(crate) user_groups_for_user_cache: Arc<ValueCache<String, Vec<StoredUserGroup>>>,
|
||||
pub(crate) auth_api_key_last_used_cache: Arc<AuthApiKeyLastUsedCache>,
|
||||
pub(crate) oauth_refresh: Arc<provider_transport::LocalOAuthRefreshCoordinator>,
|
||||
pub(crate) direct_plan_bypass_cache: Arc<DirectPlanBypassCache>,
|
||||
|
||||
@@ -29,9 +29,9 @@ use super::super::async_task::{
|
||||
spawn_video_task_poller, VideoTaskPollerConfig, VideoTaskService, VideoTaskTruthSourceMode,
|
||||
};
|
||||
use super::super::cache::{
|
||||
AuthApiKeyLastUsedCache, AuthContextCache, DashboardResponseCache, DirectPlanBypassCache,
|
||||
SchedulerAffinityCache, SchedulerAffinitySnapshotEntry, SchedulerAffinityTarget,
|
||||
SystemConfigCache,
|
||||
AuthApiKeyLastUsedCache, AuthContextCache, AuthSnapshotCache, DashboardResponseCache,
|
||||
DirectPlanBypassCache, JsonValueCache, SchedulerAffinityCache, SchedulerAffinitySnapshotEntry,
|
||||
SchedulerAffinityTarget, SystemConfigCache, ValueCache,
|
||||
};
|
||||
use super::super::data::{GatewayDataConfig, GatewayDataState};
|
||||
use super::super::fallback_metrics;
|
||||
@@ -62,7 +62,7 @@ use crate::maintenance::spawn_usage_cleanup_worker;
|
||||
use crate::maintenance::spawn_usage_counter_flush_worker;
|
||||
use crate::maintenance::spawn_wallet_daily_usage_aggregation_worker;
|
||||
|
||||
const SYSTEM_CONFIG_CACHE_TTL: Duration = Duration::from_secs(3);
|
||||
const SYSTEM_CONFIG_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
const SCHEDULER_AFFECTING_SYSTEM_CONFIG_KEYS: &[&str] = &[
|
||||
"enable_format_conversion",
|
||||
"keep_priority_on_conversion",
|
||||
@@ -236,6 +236,13 @@ impl AppState {
|
||||
distributed_request_gate: None,
|
||||
client,
|
||||
auth_context_cache: Arc::new(AuthContextCache::default()),
|
||||
auth_snapshot_cache: Arc::new(AuthSnapshotCache::default()),
|
||||
user_model_capability_settings_cache: Arc::new(JsonValueCache::default()),
|
||||
user_feature_settings_cache: Arc::new(JsonValueCache::default()),
|
||||
auth_api_key_force_capabilities_cache: Arc::new(JsonValueCache::default()),
|
||||
auth_api_key_feature_settings_cache: Arc::new(JsonValueCache::default()),
|
||||
provider_quota_snapshot_cache: Arc::new(ValueCache::default()),
|
||||
user_groups_for_user_cache: Arc::new(ValueCache::default()),
|
||||
auth_api_key_last_used_cache: Arc::new(AuthApiKeyLastUsedCache::default()),
|
||||
oauth_refresh: Arc::new(provider_transport::LocalOAuthRefreshCoordinator::new()),
|
||||
direct_plan_bypass_cache: Arc::new(DirectPlanBypassCache::default()),
|
||||
@@ -502,6 +509,11 @@ impl AppState {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let _guard = self.system_config_cache.load_guard().await;
|
||||
if let Some(value) = self.system_config_cache.get(key, SYSTEM_CONFIG_CACHE_TTL) {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let value = self
|
||||
.data
|
||||
.find_system_config_value(key)
|
||||
@@ -586,6 +598,13 @@ impl AppState {
|
||||
|
||||
pub(crate) fn invalidate_auth_context_cache(&self) {
|
||||
self.auth_context_cache.clear();
|
||||
self.auth_snapshot_cache.clear();
|
||||
self.user_model_capability_settings_cache.clear();
|
||||
self.user_feature_settings_cache.clear();
|
||||
self.auth_api_key_force_capabilities_cache.clear();
|
||||
self.auth_api_key_feature_settings_cache.clear();
|
||||
self.provider_quota_snapshot_cache.clear();
|
||||
self.user_groups_for_user_cache.clear();
|
||||
}
|
||||
|
||||
fn remember_system_config_write(&self, key: &str, value: Option<serde_json::Value>) {
|
||||
@@ -896,6 +915,9 @@ impl AppState {
|
||||
),
|
||||
}
|
||||
}
|
||||
if let Some(summary) = self.data.database_pool_summary() {
|
||||
samples.extend(database_pool_metric_samples(&summary));
|
||||
}
|
||||
samples.extend(self.tunnel.metric_samples());
|
||||
samples.extend(self.fallback_metrics.metric_samples());
|
||||
samples
|
||||
@@ -1279,6 +1301,69 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn database_pool_metric_samples(summary: &aether_data::DatabasePoolSummary) -> Vec<MetricSample> {
|
||||
let labels = vec![MetricLabel::new("driver", summary.driver.to_string())];
|
||||
let usage_basis_points = if summary.usage_rate.is_finite() && summary.usage_rate > 0.0 {
|
||||
(summary.usage_rate * 100.0).round() as u64
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let under_maintenance_pressure =
|
||||
GatewayDataState::database_pool_summary_under_maintenance_pressure(summary);
|
||||
|
||||
vec![
|
||||
MetricSample::new(
|
||||
"database_pool_checked_out_connections",
|
||||
"Number of database connections currently checked out from the gateway pool.",
|
||||
MetricKind::Gauge,
|
||||
summary.checked_out as u64,
|
||||
)
|
||||
.with_labels(labels.clone()),
|
||||
MetricSample::new(
|
||||
"database_pool_idle_connections",
|
||||
"Number of idle database connections currently available in the gateway pool.",
|
||||
MetricKind::Gauge,
|
||||
summary.idle as u64,
|
||||
)
|
||||
.with_labels(labels.clone()),
|
||||
MetricSample::new(
|
||||
"database_pool_size_connections",
|
||||
"Current number of database connections opened by the gateway pool.",
|
||||
MetricKind::Gauge,
|
||||
summary.pool_size as u64,
|
||||
)
|
||||
.with_labels(labels.clone()),
|
||||
MetricSample::new(
|
||||
"database_pool_max_connections",
|
||||
"Configured maximum number of database connections for the gateway pool.",
|
||||
MetricKind::Gauge,
|
||||
summary.max_connections as u64,
|
||||
)
|
||||
.with_labels(labels.clone()),
|
||||
MetricSample::new(
|
||||
"database_pool_usage_basis_points",
|
||||
"Database pool usage rate in basis points, where 10000 means 100 percent.",
|
||||
MetricKind::Gauge,
|
||||
usage_basis_points,
|
||||
)
|
||||
.with_labels(labels.clone()),
|
||||
MetricSample::new(
|
||||
"database_pool_idle_reserve_connections",
|
||||
"Idle database connections reserved for foreground traffic before maintenance defers.",
|
||||
MetricKind::Gauge,
|
||||
GatewayDataState::maintenance_pool_idle_reserve(summary) as u64,
|
||||
)
|
||||
.with_labels(labels.clone()),
|
||||
MetricSample::new(
|
||||
"database_pool_under_maintenance_pressure",
|
||||
"Whether maintenance workers should currently defer for foreground database pool capacity.",
|
||||
MetricKind::Gauge,
|
||||
u64::from(under_maintenance_pressure),
|
||||
)
|
||||
.with_labels(labels),
|
||||
]
|
||||
}
|
||||
|
||||
fn should_preserve_runtime_miss_diagnostic(
|
||||
existing: &LocalExecutionRuntimeMissDiagnostic,
|
||||
next: &LocalExecutionRuntimeMissDiagnostic,
|
||||
|
||||
@@ -1,17 +1,35 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::{AuthApiKeyFeatureCacheKey, AuthApiKeyIdentityCacheKey};
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const AUTH_API_KEY_RUNTIME_JSON_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn read_auth_api_key_force_capabilities(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
Ok(self
|
||||
.list_auth_api_key_export_records_by_ids(&[api_key_id.to_string()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|record| record.api_key_id == api_key_id && record.user_id == user_id)
|
||||
.and_then(|record| record.force_capabilities))
|
||||
let cache_key = AuthApiKeyIdentityCacheKey::new(user_id, api_key_id);
|
||||
if cache_key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.auth_api_key_force_capabilities_cache
|
||||
.get_or_load(
|
||||
cache_key,
|
||||
AUTH_API_KEY_RUNTIME_JSON_CACHE_TTL,
|
||||
|| async move {
|
||||
let value = self
|
||||
.list_auth_api_key_export_records_by_ids(&[api_key_id.to_string()])
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|record| record.api_key_id == api_key_id && record.user_id == user_id)
|
||||
.and_then(|record| record.force_capabilities);
|
||||
Ok(value)
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_auth_api_key_feature_settings(
|
||||
@@ -20,10 +38,22 @@ impl AppState {
|
||||
api_key_id: &str,
|
||||
is_standalone: bool,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.data
|
||||
.read_auth_api_key_feature_settings(user_id, api_key_id, is_standalone)
|
||||
let cache_key = AuthApiKeyFeatureCacheKey::new(user_id, api_key_id, is_standalone);
|
||||
if cache_key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.auth_api_key_feature_settings_cache
|
||||
.get_or_load(
|
||||
cache_key,
|
||||
AUTH_API_KEY_RUNTIME_JSON_CACHE_TTL,
|
||||
|| async move {
|
||||
self.data
|
||||
.read_auth_api_key_feature_settings(user_id, api_key_id, is_standalone)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_auth_api_key_export_records_by_user_ids(
|
||||
|
||||
@@ -2,11 +2,73 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use aether_data::repository::auth::{AuthApiKeyLookupKey, ResolvedAuthApiKeySnapshotReader};
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cache::AuthSnapshotCacheKey;
|
||||
use crate::data::auth::GatewayAuthApiKeySnapshot;
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const AUTH_API_KEY_SNAPSHOT_RUNTIME_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
use super::super::super::{AUTH_API_KEY_LAST_USED_MAX_ENTRIES, AUTH_API_KEY_LAST_USED_TTL};
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn read_cached_auth_api_key_snapshot(
|
||||
&self,
|
||||
user_id: &str,
|
||||
api_key_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<GatewayAuthApiKeySnapshot>, GatewayError> {
|
||||
let cache_key = AuthSnapshotCacheKey::user_api_key_ids(user_id, api_key_id);
|
||||
if cache_key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
self.auth_snapshot_cache
|
||||
.get_or_load(
|
||||
cache_key,
|
||||
AUTH_API_KEY_SNAPSHOT_RUNTIME_CACHE_TTL,
|
||||
|| async move {
|
||||
self.data
|
||||
.read_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn read_cached_auth_api_key_snapshot_by_key_hash(
|
||||
&self,
|
||||
key_hash: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<GatewayAuthApiKeySnapshot>, GatewayError> {
|
||||
let cache_key = AuthSnapshotCacheKey::key_hash(key_hash);
|
||||
if cache_key.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let snapshot = self
|
||||
.auth_snapshot_cache
|
||||
.get_or_load(
|
||||
cache_key.clone(),
|
||||
AUTH_API_KEY_SNAPSHOT_RUNTIME_CACHE_TTL,
|
||||
|| async move {
|
||||
self.data
|
||||
.read_auth_api_key_snapshot_by_key_hash(key_hash, now_unix_secs)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if let Some(snapshot) = snapshot.as_ref() {
|
||||
self.auth_snapshot_cache.insert(
|
||||
AuthSnapshotCacheKey::user_api_key_ids(&snapshot.user_id, &snapshot.api_key_id),
|
||||
Some(snapshot.clone()),
|
||||
AUTH_API_KEY_SNAPSHOT_RUNTIME_CACHE_TTL,
|
||||
);
|
||||
}
|
||||
Ok(snapshot)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_auth_api_key_snapshots_by_ids(
|
||||
&self,
|
||||
api_key_ids: &[String],
|
||||
|
||||
@@ -2,6 +2,9 @@ use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::constants::{BUILTIN_DEFAULT_USER_GROUP_ID, DEFAULT_USER_GROUP_CONFIG_KEY};
|
||||
use crate::{AppState, GatewayError};
|
||||
use std::time::Duration;
|
||||
|
||||
const USER_GROUPS_FOR_USER_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn assign_default_group_to_self_registered_user(
|
||||
@@ -323,10 +326,21 @@ impl AppState {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Vec<aether_data::repository::users::StoredUserGroup>, GatewayError> {
|
||||
self.data
|
||||
.list_user_groups_for_user(user_id)
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let cache_key = user_id.to_string();
|
||||
self.user_groups_for_user_cache
|
||||
.get_or_load(cache_key, USER_GROUPS_FOR_USER_CACHE_TTL, || async move {
|
||||
self.data
|
||||
.list_user_groups_for_user(user_id)
|
||||
.await
|
||||
.map(Some)
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
.map(|value| value.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_user_group_memberships_by_user_ids(
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{AppState, GatewayError};
|
||||
|
||||
const USER_RUNTIME_JSON_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn read_user_model_capability_settings(
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
#[cfg(test)]
|
||||
if let Some(store) = self.auth_user_model_capability_store.as_ref() {
|
||||
if let Some(settings) = store
|
||||
@@ -17,11 +25,17 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
let users = self.list_non_admin_export_users().await?;
|
||||
Ok(users
|
||||
.into_iter()
|
||||
.find(|user| user.id == user_id)
|
||||
.and_then(|user| user.model_capability_settings))
|
||||
let cache_key = user_id.to_string();
|
||||
self.user_model_capability_settings_cache
|
||||
.get_or_load(cache_key, USER_RUNTIME_JSON_CACHE_TTL, || async move {
|
||||
Ok(self
|
||||
.data
|
||||
.find_export_user_by_id(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))?
|
||||
.and_then(|user| user.model_capability_settings))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_model_capability_settings(
|
||||
@@ -56,10 +70,19 @@ impl AppState {
|
||||
&self,
|
||||
user_id: &str,
|
||||
) -> Result<Option<serde_json::Value>, GatewayError> {
|
||||
self.data
|
||||
.read_user_feature_settings(user_id)
|
||||
let user_id = user_id.trim();
|
||||
if user_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let cache_key = user_id.to_string();
|
||||
self.user_feature_settings_cache
|
||||
.get_or_load(cache_key, USER_RUNTIME_JSON_CACHE_TTL, || async move {
|
||||
self.data
|
||||
.read_user_feature_settings(user_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn update_user_feature_settings(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::{AppState, GatewayError};
|
||||
use aether_data_contracts::repository::{candidate_selection, candidates, quota};
|
||||
use std::time::Duration;
|
||||
|
||||
const PROVIDER_QUOTA_RUNTIME_CACHE_TTL: Duration = Duration::from_secs(5);
|
||||
|
||||
impl AppState {
|
||||
pub(crate) async fn list_minimal_candidate_selection_rows_for_api_format(
|
||||
@@ -71,10 +74,19 @@ impl AppState {
|
||||
&self,
|
||||
provider_id: &str,
|
||||
) -> Result<Option<quota::StoredProviderQuotaSnapshot>, GatewayError> {
|
||||
self.data
|
||||
.find_provider_quota_by_provider_id(provider_id)
|
||||
let provider_id = provider_id.trim();
|
||||
if provider_id.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let cache_key = provider_id.to_string();
|
||||
self.provider_quota_snapshot_cache
|
||||
.get_or_load(cache_key, PROVIDER_QUOTA_RUNTIME_CACHE_TTL, || async move {
|
||||
self.data
|
||||
.find_provider_quota_by_provider_id(provider_id)
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
})
|
||||
.await
|
||||
.map_err(|err| GatewayError::Internal(err.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) async fn read_provider_quota_snapshots(
|
||||
|
||||
@@ -1056,10 +1056,22 @@ pub fn codex_runtime_invalid_reason(
|
||||
{
|
||||
Some(codex_structured_invalid_reason(403, upstream_message))
|
||||
}
|
||||
403 => Some(codex_generic_forbidden_runtime_invalid_reason(
|
||||
upstream_message,
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_generic_forbidden_runtime_invalid_reason(upstream_message: Option<&str>) -> String {
|
||||
let detail = upstream_message
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|message| format!("Codex Token 已失效 (403): {message}"))
|
||||
.unwrap_or_else(|| "Codex Token 已失效 (403)".to_string());
|
||||
format!("{OAUTH_EXPIRED_PREFIX}{detail}")
|
||||
}
|
||||
|
||||
pub fn codex_soft_request_failure_reason(
|
||||
status_code: u16,
|
||||
upstream_message: Option<&str>,
|
||||
@@ -1808,8 +1820,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_runtime_invalid_reason_ignores_generic_403() {
|
||||
assert_eq!(codex_runtime_invalid_reason(403, Some("forbidden")), None);
|
||||
fn codex_runtime_invalid_reason_marks_generic_403_as_token_invalid() {
|
||||
assert_eq!(
|
||||
codex_runtime_invalid_reason(403, Some("forbidden")),
|
||||
Some(format!(
|
||||
"{OAUTH_EXPIRED_PREFIX}Codex Token 已失效 (403): forbidden"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- High-concurrency gateway read/cleanup paths.
|
||||
-- MySQL stores usage timestamps as unix milliseconds.
|
||||
|
||||
CREATE INDEX idx_usage_created_id_desc
|
||||
ON `usage` (created_at_unix_ms DESC, id ASC);
|
||||
|
||||
CREATE INDEX idx_usage_user_created_id_desc
|
||||
ON `usage` (user_id, created_at_unix_ms DESC, id ASC);
|
||||
|
||||
CREATE INDEX idx_usage_api_format_created_id_desc
|
||||
ON `usage` (api_format, created_at_unix_ms DESC, id ASC);
|
||||
|
||||
CREATE INDEX idx_usage_status_created_id_desc
|
||||
ON `usage` (status, created_at_unix_ms DESC, id ASC);
|
||||
|
||||
CREATE INDEX idx_request_candidates_provider_created
|
||||
ON request_candidates (provider_id, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX idx_request_candidates_api_key_created
|
||||
ON request_candidates (api_key_id, created_at ASC, id ASC);
|
||||
|
||||
CREATE INDEX idx_background_task_runs_status_created
|
||||
ON background_task_runs (status, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
|
||||
CREATE INDEX idx_background_task_runs_kind_created
|
||||
ON background_task_runs (kind, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
-- High-concurrency gateway read/cleanup paths.
|
||||
-- These indexes keep request audit, candidate cleanup, and background task list
|
||||
-- queries bounded as append-only tables grow under 6k+ long-lived requests.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created_id_desc
|
||||
ON public.usage USING btree (created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_user_created_id_desc
|
||||
ON public.usage USING btree (user_id, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_api_format_created_id_desc
|
||||
ON public.usage USING btree (api_format, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_status_created_id_desc
|
||||
ON public.usage USING btree (status, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_monitoring_errors_created_id_desc
|
||||
ON public.usage USING btree (created_at DESC, id ASC)
|
||||
WHERE (
|
||||
lower(BTRIM(COALESCE(status, ''))) IN ('failed', 'error')
|
||||
OR (error_category IS NOT NULL AND BTRIM(error_category) <> '')
|
||||
OR (
|
||||
BTRIM(COALESCE(status, '')) = ''
|
||||
AND (
|
||||
COALESCE(status_code, 0) >= 400
|
||||
OR (error_message IS NOT NULL AND BTRIM(error_message) <> '')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_endpoint_status_created
|
||||
ON public.request_candidates USING btree (endpoint_id, status, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_provider_created
|
||||
ON public.request_candidates USING btree (provider_id, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_api_key_created
|
||||
ON public.request_candidates USING btree (api_key_id, created_at ASC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_status_created
|
||||
ON public.background_task_runs USING btree (status, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_kind_created
|
||||
ON public.background_task_runs USING btree (kind, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
@@ -0,0 +1,26 @@
|
||||
-- High-concurrency gateway read/cleanup paths.
|
||||
-- SQLite remains single-node/lightweight but benefits from the same bounded scans.
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created_id_desc
|
||||
ON "usage" (created_at_unix_ms DESC, request_id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_user_created_id_desc
|
||||
ON "usage" (user_id, created_at_unix_ms DESC, request_id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_api_format_created_id_desc
|
||||
ON "usage" (api_format, created_at_unix_ms DESC, request_id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_status_created_id_desc
|
||||
ON "usage" (status, created_at_unix_ms DESC, request_id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_provider_created
|
||||
ON request_candidates (provider_id, created_at DESC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_api_key_created
|
||||
ON request_candidates (api_key_id, created_at ASC, id ASC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_status_created
|
||||
ON background_task_runs (status, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_kind_created
|
||||
ON background_task_runs (kind, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
@@ -269,6 +269,30 @@ CREATE INDEX IF NOT EXISTS idx_rc_provider_status_created ON public.request_cand
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_request_candidates_endpoint_status_created; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_endpoint_status_created ON public.request_candidates USING btree (endpoint_id, status, created_at DESC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_request_candidates_provider_created; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_provider_created ON public.request_candidates USING btree (provider_id, created_at DESC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_request_candidates_api_key_created; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_request_candidates_api_key_created ON public.request_candidates USING btree (api_key_id, created_at ASC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_rc_request_id_status; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
@@ -485,6 +509,57 @@ CREATE INDEX IF NOT EXISTS idx_usage_apikey_created ON public.usage USING btree
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_usage_created_id_desc; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created_id_desc ON public.usage USING btree (created_at DESC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_usage_user_created_id_desc; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_user_created_id_desc ON public.usage USING btree (user_id, created_at DESC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_usage_api_format_created_id_desc; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_api_format_created_id_desc ON public.usage USING btree (api_format, created_at DESC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_usage_status_created_id_desc; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_status_created_id_desc ON public.usage USING btree (status, created_at DESC, id ASC);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_usage_monitoring_errors_created_id_desc; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_monitoring_errors_created_id_desc ON public.usage USING btree (created_at DESC, id ASC)
|
||||
WHERE (
|
||||
lower(BTRIM(COALESCE(status, ''))) IN ('failed', 'error')
|
||||
OR (error_category IS NOT NULL AND BTRIM(error_category) <> '')
|
||||
OR (
|
||||
BTRIM(COALESCE(status, '')) = ''
|
||||
AND (
|
||||
COALESCE(status_code, 0) >= 400
|
||||
OR (error_message IS NOT NULL AND BTRIM(error_message) <> '')
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
|
||||
--
|
||||
-- Name: idx_usage_billing_finalized_wallet; Type: INDEX; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
@@ -28,6 +28,10 @@ CREATE INDEX IF NOT EXISTS idx_background_task_runs_kind
|
||||
ON public.background_task_runs (kind);
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_created_at
|
||||
ON public.background_task_runs (created_at_unix_secs DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_status_created
|
||||
ON public.background_task_runs (status, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_background_task_runs_kind_created
|
||||
ON public.background_task_runs (kind, created_at_unix_secs DESC, updated_at_unix_secs DESC);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.background_task_events (
|
||||
id character varying(64) PRIMARY KEY,
|
||||
|
||||
@@ -7,7 +7,7 @@ use tracing::info;
|
||||
// Generated by build.rs from schema/bootstrap/postgres.
|
||||
pub(crate) static EMPTY_DATABASE_SNAPSHOT_SQL: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/empty_database_snapshot.sql"));
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260528010000;
|
||||
pub(crate) const EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION: i64 = 20260528020000;
|
||||
|
||||
const PUBLIC_BASE_TABLE_COUNT_SQL: &str = r#"
|
||||
SELECT COUNT(*)::BIGINT
|
||||
|
||||
@@ -320,6 +320,7 @@ fn empty_database_snapshot_covers_current_cutoff_versions() {
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
20260528010000,
|
||||
20260528020000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -452,6 +453,9 @@ fn empty_database_snapshot_sql_includes_usage_body_blobs_and_audit_admin_role()
|
||||
);
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("pool_member_scores_scheduler_account_rank_idx"));
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("idx_video_tasks_due_poll"));
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("idx_usage_created_id_desc"));
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("idx_request_candidates_endpoint_status_created"));
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("idx_background_task_runs_status_created"));
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("request_count bigint DEFAULT 0"));
|
||||
assert!(EMPTY_DATABASE_SNAPSHOT_SQL.contains("usage_count bigint DEFAULT 0 NOT NULL"));
|
||||
}
|
||||
@@ -727,6 +731,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||
20260524000000,
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
20260528020000,
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -753,6 +758,7 @@ fn mysql_and_sqlite_migrations_include_enabled_incrementals() {
|
||||
20260524000000,
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
20260528020000,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -1557,6 +1563,7 @@ fn pending_migrations_from_applied_skips_versions_already_applied() {
|
||||
20260527000000,
|
||||
20260528000000,
|
||||
20260528010000,
|
||||
20260528020000,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_testkit::{
|
||||
fetch_prometheus_samples, find_metric_value_u64, run_http_load_probe, HttpLoadProbeConfig,
|
||||
HttpLoadProbeResponseMode, HttpLoadProbeResult, PrometheusSample,
|
||||
};
|
||||
use reqwest::Method;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
load: HttpLoadProbeConfig,
|
||||
metrics_url: String,
|
||||
sample_interval: Duration,
|
||||
output_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GatewayPressureReport {
|
||||
suite: &'static str,
|
||||
target_url: String,
|
||||
metrics_url: String,
|
||||
sample_interval_ms: u64,
|
||||
load: HttpLoadProbeResult,
|
||||
metrics: GatewayPressureMetricsSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize)]
|
||||
struct GatewayPressureMetricsSummary {
|
||||
samples: usize,
|
||||
db_pool_max_checked_out: u64,
|
||||
db_pool_min_idle: Option<u64>,
|
||||
db_pool_max_size: u64,
|
||||
db_pool_max_connections: u64,
|
||||
db_pool_max_usage_basis_points: u64,
|
||||
db_pool_max_idle_reserve: u64,
|
||||
db_pool_pressure_samples: usize,
|
||||
gateway_requests_max_in_flight: u64,
|
||||
gateway_requests_max_rejected_total: u64,
|
||||
gateway_requests_distributed_max_in_flight: u64,
|
||||
gateway_requests_distributed_max_rejected_total: u64,
|
||||
}
|
||||
|
||||
impl GatewayPressureMetricsSummary {
|
||||
fn observe(&mut self, samples: &[PrometheusSample]) {
|
||||
self.samples += 1;
|
||||
self.db_pool_max_checked_out = self
|
||||
.db_pool_max_checked_out
|
||||
.max(metric_max(samples, "database_pool_checked_out_connections"));
|
||||
let idle = metric_min(samples, "database_pool_idle_connections");
|
||||
self.db_pool_min_idle = match (self.db_pool_min_idle, idle) {
|
||||
(Some(current), Some(next)) => Some(current.min(next)),
|
||||
(None, Some(next)) => Some(next),
|
||||
(current, None) => current,
|
||||
};
|
||||
self.db_pool_max_size = self
|
||||
.db_pool_max_size
|
||||
.max(metric_max(samples, "database_pool_size_connections"));
|
||||
self.db_pool_max_connections = self
|
||||
.db_pool_max_connections
|
||||
.max(metric_max(samples, "database_pool_max_connections"));
|
||||
self.db_pool_max_usage_basis_points = self
|
||||
.db_pool_max_usage_basis_points
|
||||
.max(metric_max(samples, "database_pool_usage_basis_points"));
|
||||
self.db_pool_max_idle_reserve = self.db_pool_max_idle_reserve.max(metric_max(
|
||||
samples,
|
||||
"database_pool_idle_reserve_connections",
|
||||
));
|
||||
if metric_max(samples, "database_pool_under_maintenance_pressure") > 0 {
|
||||
self.db_pool_pressure_samples += 1;
|
||||
}
|
||||
self.gateway_requests_max_in_flight = self.gateway_requests_max_in_flight.max(
|
||||
find_metric_value_u64(
|
||||
samples,
|
||||
"concurrency_in_flight",
|
||||
&[("gate", "gateway_requests")],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.gateway_requests_max_rejected_total = self.gateway_requests_max_rejected_total.max(
|
||||
find_metric_value_u64(
|
||||
samples,
|
||||
"concurrency_rejected_total",
|
||||
&[("gate", "gateway_requests")],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.gateway_requests_distributed_max_in_flight =
|
||||
self.gateway_requests_distributed_max_in_flight.max(
|
||||
find_metric_value_u64(
|
||||
samples,
|
||||
"concurrency_in_flight",
|
||||
&[("gate", "gateway_requests_distributed")],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
self.gateway_requests_distributed_max_rejected_total =
|
||||
self.gateway_requests_distributed_max_rejected_total.max(
|
||||
find_metric_value_u64(
|
||||
samples,
|
||||
"concurrency_rejected_total",
|
||||
&[("gate", "gateway_requests_distributed")],
|
||||
)
|
||||
.unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let summary = Arc::new(Mutex::new(GatewayPressureMetricsSummary::default()));
|
||||
let sampler = spawn_metrics_sampler(
|
||||
config.metrics_url.clone(),
|
||||
config.sample_interval,
|
||||
Arc::clone(&stop),
|
||||
Arc::clone(&summary),
|
||||
);
|
||||
|
||||
let load = run_http_load_probe(&config.load)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
stop.store(true, Ordering::Release);
|
||||
sampler.await??;
|
||||
|
||||
if let Ok(samples) = fetch_prometheus_samples(&config.metrics_url).await {
|
||||
summary.lock().await.observe(&samples);
|
||||
}
|
||||
|
||||
let report = GatewayPressureReport {
|
||||
suite: "gateway_pressure_probe",
|
||||
target_url: config.load.url,
|
||||
metrics_url: config.metrics_url,
|
||||
sample_interval_ms: config.sample_interval.as_millis() as u64,
|
||||
load,
|
||||
metrics: Arc::try_unwrap(summary)
|
||||
.unwrap_or_else(|_| panic!("metrics summary still referenced"))
|
||||
.into_inner(),
|
||||
};
|
||||
let raw = serde_json::to_string_pretty(&report)?;
|
||||
println!("{raw}");
|
||||
if let Some(path) = config.output_path.as_ref() {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(path, format!("{raw}\n"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn spawn_metrics_sampler(
|
||||
metrics_url: String,
|
||||
interval: Duration,
|
||||
stop: Arc<AtomicBool>,
|
||||
summary: Arc<Mutex<GatewayPressureMetricsSummary>>,
|
||||
) -> tokio::task::JoinHandle<Result<(), std::io::Error>> {
|
||||
tokio::spawn(async move {
|
||||
while !stop.load(Ordering::Acquire) {
|
||||
match fetch_prometheus_samples(&metrics_url).await {
|
||||
Ok(samples) => summary.lock().await.observe(&samples),
|
||||
Err(err) => {
|
||||
eprintln!("gateway pressure probe metrics sample failed: {err}");
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(interval).await;
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut target_url: Option<String> = None;
|
||||
let mut metrics_url: Option<String> = None;
|
||||
let mut total_requests: Option<usize> = None;
|
||||
let mut concurrency: Option<usize> = None;
|
||||
let mut timeout_ms: Option<u64> = None;
|
||||
let mut sample_interval_ms: u64 = 500;
|
||||
let mut method = Method::GET;
|
||||
let mut headers = BTreeMap::new();
|
||||
let mut body: Option<Vec<u8>> = None;
|
||||
let mut response_mode = HttpLoadProbeResponseMode::HeadersOnly;
|
||||
let mut output_path = None;
|
||||
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--url" => target_url = Some(next_value(&mut iter, "--url")?),
|
||||
"--metrics-url" => metrics_url = Some(next_value(&mut iter, "--metrics-url")?),
|
||||
"--requests" => total_requests = Some(next_value(&mut iter, "--requests")?.parse()?),
|
||||
"--concurrency" => concurrency = Some(next_value(&mut iter, "--concurrency")?.parse()?),
|
||||
"--timeout-ms" => timeout_ms = Some(next_value(&mut iter, "--timeout-ms")?.parse()?),
|
||||
"--sample-interval-ms" => {
|
||||
sample_interval_ms = next_value(&mut iter, "--sample-interval-ms")?.parse()?
|
||||
}
|
||||
"--method" => {
|
||||
method = Method::from_bytes(next_value(&mut iter, "--method")?.as_bytes())?
|
||||
}
|
||||
"--header" | "-H" => {
|
||||
let (name, value) = parse_header_arg(&next_value(&mut iter, "--header")?)?;
|
||||
headers.insert(name, value);
|
||||
}
|
||||
"--body" => body = Some(next_value(&mut iter, "--body")?.into_bytes()),
|
||||
"--body-file" => body = Some(std::fs::read(next_value(&mut iter, "--body-file")?)?),
|
||||
"--response-mode" => {
|
||||
response_mode = parse_response_mode(&next_value(&mut iter, "--response-mode")?)?
|
||||
}
|
||||
"--output" => output_path = Some(PathBuf::from(next_value(&mut iter, "--output")?)),
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut load = HttpLoadProbeConfig {
|
||||
url: target_url.ok_or_else(|| {
|
||||
std::io::Error::new(std::io::ErrorKind::InvalidInput, "missing required --url")
|
||||
})?,
|
||||
total_requests: total_requests.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"missing required --requests",
|
||||
)
|
||||
})?,
|
||||
concurrency: concurrency.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"missing required --concurrency",
|
||||
)
|
||||
})?,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
response_mode,
|
||||
..HttpLoadProbeConfig::default()
|
||||
};
|
||||
if let Some(timeout_ms) = timeout_ms {
|
||||
load.timeout = Duration::from_millis(timeout_ms);
|
||||
}
|
||||
load.validate()
|
||||
.map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidInput, err))?;
|
||||
if sample_interval_ms == 0 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--sample-interval-ms must be positive",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(Config {
|
||||
load,
|
||||
metrics_url: metrics_url.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"missing required --metrics-url",
|
||||
)
|
||||
})?,
|
||||
sample_interval: Duration::from_millis(sample_interval_ms),
|
||||
output_path,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_header_arg(value: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||
let (name, value) = value
|
||||
.split_once(':')
|
||||
.or_else(|| value.split_once('='))
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--header expects `Name: value` or `Name=value`",
|
||||
)
|
||||
})?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--header name cannot be empty",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok((name.to_string(), value.trim().to_string()))
|
||||
}
|
||||
|
||||
fn parse_response_mode(
|
||||
value: &str,
|
||||
) -> Result<HttpLoadProbeResponseMode, Box<dyn std::error::Error>> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"headers" | "headers-only" | "header" => Ok(HttpLoadProbeResponseMode::HeadersOnly),
|
||||
"full" | "full-body" | "body" => Ok(HttpLoadProbeResponseMode::FullBody),
|
||||
other => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unsupported --response-mode {other}; expected headers or full"),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn metric_max(samples: &[PrometheusSample], metric_name: &str) -> u64 {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|sample| metric_name_matches(&sample.name, metric_name))
|
||||
.filter_map(|sample| sample.value.parse::<u64>().ok())
|
||||
.max()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn metric_min(samples: &[PrometheusSample], metric_name: &str) -> Option<u64> {
|
||||
samples
|
||||
.iter()
|
||||
.filter(|sample| metric_name_matches(&sample.name, metric_name))
|
||||
.filter_map(|sample| sample.value.parse::<u64>().ok())
|
||||
.min()
|
||||
}
|
||||
|
||||
fn metric_name_matches(actual: &str, expected: &str) -> bool {
|
||||
actual == expected
|
||||
|| actual
|
||||
.rsplit_once('_')
|
||||
.map(|(_, suffix)| suffix == expected)
|
||||
.unwrap_or(false)
|
||||
|| actual.ends_with(&format!("_{expected}"))
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-testkit --bin gateway_pressure_probe -- --url <URL> --metrics-url <URL> --requests <N> --concurrency <N> [--method GET] [--timeout-ms 30000] [--sample-interval-ms 500] [-H 'Name: value'] [--body JSON | --body-file path] [--response-mode headers|full] [--output /tmp/gateway_pressure.json]"
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,9 @@ fn parse_args(args: Vec<String>) -> Result<HttpLoadProbeConfig, Box<dyn std::err
|
||||
let mut concurrency: Option<usize> = None;
|
||||
let mut timeout_ms: Option<u64> = None;
|
||||
let mut method = Method::GET;
|
||||
let mut headers = std::collections::BTreeMap::new();
|
||||
let mut body: Option<Vec<u8>> = None;
|
||||
let mut response_mode = aether_testkit::HttpLoadProbeResponseMode::HeadersOnly;
|
||||
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
@@ -30,6 +33,15 @@ fn parse_args(args: Vec<String>) -> Result<HttpLoadProbeConfig, Box<dyn std::err
|
||||
"--method" => {
|
||||
method = Method::from_bytes(next_value(&mut iter, "--method")?.as_bytes())?
|
||||
}
|
||||
"--header" | "-H" => {
|
||||
let (name, value) = parse_header_arg(&next_value(&mut iter, "--header")?)?;
|
||||
headers.insert(name, value);
|
||||
}
|
||||
"--body" => body = Some(next_value(&mut iter, "--body")?.into_bytes()),
|
||||
"--body-file" => body = Some(std::fs::read(next_value(&mut iter, "--body-file")?)?),
|
||||
"--response-mode" => {
|
||||
response_mode = parse_response_mode(&next_value(&mut iter, "--response-mode")?)?
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
@@ -61,6 +73,9 @@ fn parse_args(args: Vec<String>) -> Result<HttpLoadProbeConfig, Box<dyn std::err
|
||||
)
|
||||
})?,
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
response_mode,
|
||||
..HttpLoadProbeConfig::default()
|
||||
};
|
||||
if let Some(timeout_ms) = timeout_ms {
|
||||
@@ -72,6 +87,43 @@ fn parse_args(args: Vec<String>) -> Result<HttpLoadProbeConfig, Box<dyn std::err
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn parse_header_arg(value: &str) -> Result<(String, String), Box<dyn std::error::Error>> {
|
||||
let (name, value) = value
|
||||
.split_once(':')
|
||||
.or_else(|| value.split_once('='))
|
||||
.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--header expects `Name: value` or `Name=value`",
|
||||
)
|
||||
})?;
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"--header name cannot be empty",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok((name.to_string(), value.trim().to_string()))
|
||||
}
|
||||
|
||||
fn parse_response_mode(
|
||||
value: &str,
|
||||
) -> Result<aether_testkit::HttpLoadProbeResponseMode, Box<dyn std::error::Error>> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"headers" | "headers-only" | "header" => {
|
||||
Ok(aether_testkit::HttpLoadProbeResponseMode::HeadersOnly)
|
||||
}
|
||||
"full" | "full-body" | "body" => Ok(aether_testkit::HttpLoadProbeResponseMode::FullBody),
|
||||
other => Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unsupported --response-mode {other}; expected headers or full"),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
@@ -87,6 +139,6 @@ fn next_value(
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-testkit --bin http_load_probe -- --url <URL> --requests <N> --concurrency <N> [--method GET] [--timeout-ms 30000]"
|
||||
"usage: cargo run -p aether-testkit --bin http_load_probe -- --url <URL> --requests <N> --concurrency <N> [--method GET] [--timeout-ms 30000] [-H 'Name: value'] [--body JSON | --body-file path] [--response-mode headers|full]"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
use std::convert::Infallible;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::body::{Body, Bytes};
|
||||
use axum::extract::State;
|
||||
use axum::http::{header, HeaderValue, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use serde_json::json;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Config {
|
||||
bind: SocketAddr,
|
||||
chunks: u64,
|
||||
first_byte_delay: Duration,
|
||||
chunk_delay: Duration,
|
||||
payload_bytes: usize,
|
||||
status: StatusCode,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bind: "127.0.0.1:18181"
|
||||
.parse()
|
||||
.expect("default bind address should parse"),
|
||||
chunks: 8,
|
||||
first_byte_delay: Duration::from_millis(0),
|
||||
chunk_delay: Duration::from_millis(20),
|
||||
payload_bytes: 32,
|
||||
status: StatusCode::OK,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Metrics {
|
||||
requests_total: AtomicU64,
|
||||
completed_total: AtomicU64,
|
||||
in_flight: AtomicU64,
|
||||
max_in_flight: AtomicU64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct App {
|
||||
config: Config,
|
||||
metrics: Arc<Metrics>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let config = parse_args(std::env::args().skip(1).collect())?;
|
||||
let app_state = App {
|
||||
config: config.clone(),
|
||||
metrics: Arc::new(Metrics::default()),
|
||||
};
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/metrics", get(metrics))
|
||||
.route("/v1/chat/completions", post(chat_completions))
|
||||
.route("/chat/completions", post(chat_completions))
|
||||
.route("/v1/responses", post(responses))
|
||||
.route("/responses", post(responses))
|
||||
.with_state(app_state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(config.bind).await?;
|
||||
eprintln!("mock OpenAI upstream listening on http://{}", config.bind);
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health() -> impl IntoResponse {
|
||||
(StatusCode::OK, "ok\n")
|
||||
}
|
||||
|
||||
async fn metrics(State(app): State<App>) -> Response {
|
||||
let body = format!(
|
||||
concat!(
|
||||
"# HELP mock_upstream_requests_total Total requests accepted by the mock upstream.\n",
|
||||
"# TYPE mock_upstream_requests_total counter\n",
|
||||
"mock_upstream_requests_total {}\n",
|
||||
"# HELP mock_upstream_completed_total Total requests completed by the mock upstream.\n",
|
||||
"# TYPE mock_upstream_completed_total counter\n",
|
||||
"mock_upstream_completed_total {}\n",
|
||||
"# HELP mock_upstream_in_flight Current in-flight requests/streams.\n",
|
||||
"# TYPE mock_upstream_in_flight gauge\n",
|
||||
"mock_upstream_in_flight {}\n",
|
||||
"# HELP mock_upstream_max_in_flight Maximum in-flight requests/streams observed.\n",
|
||||
"# TYPE mock_upstream_max_in_flight gauge\n",
|
||||
"mock_upstream_max_in_flight {}\n"
|
||||
),
|
||||
app.metrics.requests_total.load(Ordering::Acquire),
|
||||
app.metrics.completed_total.load(Ordering::Acquire),
|
||||
app.metrics.in_flight.load(Ordering::Acquire),
|
||||
app.metrics.max_in_flight.load(Ordering::Acquire),
|
||||
);
|
||||
(StatusCode::OK, body).into_response()
|
||||
}
|
||||
|
||||
async fn chat_completions(State(app): State<App>, body: Bytes) -> Response {
|
||||
let stream = request_wants_stream(&body);
|
||||
record_request_started(&app.metrics);
|
||||
if app.config.status != StatusCode::OK {
|
||||
record_request_completed(&app.metrics);
|
||||
return (app.config.status, "mock upstream error\n").into_response();
|
||||
}
|
||||
if stream {
|
||||
return build_chat_sse_response(app);
|
||||
}
|
||||
let payload = json!({
|
||||
"id": "chatcmpl-mock",
|
||||
"object": "chat.completion",
|
||||
"created": current_unix_secs(),
|
||||
"model": request_model(&body).unwrap_or_else(|| "mock-model".to_string()),
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": mock_payload(app.config.payload_bytes)
|
||||
},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": app.config.chunks.max(1),
|
||||
"total_tokens": app.config.chunks.max(1) + 1
|
||||
}
|
||||
});
|
||||
record_request_completed(&app.metrics);
|
||||
axum::Json(payload).into_response()
|
||||
}
|
||||
|
||||
async fn responses(State(app): State<App>, body: Bytes) -> Response {
|
||||
let stream = request_wants_stream(&body);
|
||||
record_request_started(&app.metrics);
|
||||
if app.config.status != StatusCode::OK {
|
||||
record_request_completed(&app.metrics);
|
||||
return (app.config.status, "mock upstream error\n").into_response();
|
||||
}
|
||||
if stream {
|
||||
return build_responses_sse_response(app);
|
||||
}
|
||||
let payload = json!({
|
||||
"id": "resp_mock",
|
||||
"object": "response",
|
||||
"created_at": current_unix_secs(),
|
||||
"model": request_model(&body).unwrap_or_else(|| "mock-model".to_string()),
|
||||
"output": [{
|
||||
"type": "message",
|
||||
"id": "msg_mock",
|
||||
"role": "assistant",
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": mock_payload(app.config.payload_bytes)
|
||||
}]
|
||||
}],
|
||||
"usage": {
|
||||
"input_tokens": 1,
|
||||
"output_tokens": app.config.chunks.max(1),
|
||||
"total_tokens": app.config.chunks.max(1) + 1
|
||||
}
|
||||
});
|
||||
record_request_completed(&app.metrics);
|
||||
axum::Json(payload).into_response()
|
||||
}
|
||||
|
||||
fn build_chat_sse_response(app: App) -> Response {
|
||||
let metrics = Arc::clone(&app.metrics);
|
||||
let config = app.config.clone();
|
||||
let stream = async_stream::stream! {
|
||||
if !config.first_byte_delay.is_zero() {
|
||||
tokio::time::sleep(config.first_byte_delay).await;
|
||||
}
|
||||
for index in 0..config.chunks {
|
||||
if index > 0 && !config.chunk_delay.is_zero() {
|
||||
tokio::time::sleep(config.chunk_delay).await;
|
||||
}
|
||||
let payload = json!({
|
||||
"id": "chatcmpl-mock",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": current_unix_secs(),
|
||||
"model": "mock-model",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"delta": {
|
||||
"content": mock_payload(config.payload_bytes)
|
||||
},
|
||||
"finish_reason": serde_json::Value::Null
|
||||
}]
|
||||
});
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(format!("data: {payload}\n\n")));
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(
|
||||
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
|
||||
));
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from("data: [DONE]\n\n"));
|
||||
record_request_completed(&metrics);
|
||||
};
|
||||
sse_response(Body::from_stream(stream))
|
||||
}
|
||||
|
||||
fn build_responses_sse_response(app: App) -> Response {
|
||||
let metrics = Arc::clone(&app.metrics);
|
||||
let config = app.config.clone();
|
||||
let stream = async_stream::stream! {
|
||||
if !config.first_byte_delay.is_zero() {
|
||||
tokio::time::sleep(config.first_byte_delay).await;
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(
|
||||
"event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_mock\",\"status\":\"in_progress\"}}\n\n",
|
||||
));
|
||||
for index in 0..config.chunks {
|
||||
if index > 0 && !config.chunk_delay.is_zero() {
|
||||
tokio::time::sleep(config.chunk_delay).await;
|
||||
}
|
||||
let payload = json!({
|
||||
"type": "response.output_text.delta",
|
||||
"item_id": "msg_mock",
|
||||
"output_index": 0,
|
||||
"content_index": 0,
|
||||
"delta": mock_payload(config.payload_bytes)
|
||||
});
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(format!("event: response.output_text.delta\ndata: {payload}\n\n")));
|
||||
}
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from(
|
||||
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_mock\",\"status\":\"completed\"}}\n\n",
|
||||
));
|
||||
yield Ok::<Bytes, Infallible>(Bytes::from("data: [DONE]\n\n"));
|
||||
record_request_completed(&metrics);
|
||||
};
|
||||
sse_response(Body::from_stream(stream))
|
||||
}
|
||||
|
||||
fn sse_response(body: Body) -> Response {
|
||||
let mut response = Response::new(body);
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
header::CONTENT_TYPE,
|
||||
HeaderValue::from_static("text/event-stream; charset=utf-8"),
|
||||
);
|
||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
||||
response
|
||||
}
|
||||
|
||||
fn request_wants_stream(body: &[u8]) -> bool {
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| value.get("stream").and_then(serde_json::Value::as_bool))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn request_model(body: &[u8]) -> Option<String> {
|
||||
serde_json::from_slice::<serde_json::Value>(body)
|
||||
.ok()
|
||||
.and_then(|value| {
|
||||
value
|
||||
.get("model")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
fn mock_payload(bytes: usize) -> String {
|
||||
if bytes == 0 {
|
||||
return String::new();
|
||||
}
|
||||
"x".repeat(bytes)
|
||||
}
|
||||
|
||||
fn record_request_started(metrics: &Metrics) {
|
||||
metrics.requests_total.fetch_add(1, Ordering::AcqRel);
|
||||
let in_flight = metrics.in_flight.fetch_add(1, Ordering::AcqRel) + 1;
|
||||
metrics.max_in_flight.fetch_max(in_flight, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn record_request_completed(metrics: &Metrics) {
|
||||
metrics.completed_total.fetch_add(1, Ordering::AcqRel);
|
||||
metrics.in_flight.fetch_sub(1, Ordering::AcqRel);
|
||||
}
|
||||
|
||||
fn current_unix_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn parse_args(args: Vec<String>) -> Result<Config, Box<dyn std::error::Error>> {
|
||||
let mut config = Config::default();
|
||||
let mut iter = args.into_iter();
|
||||
while let Some(arg) = iter.next() {
|
||||
match arg.as_str() {
|
||||
"--bind" => config.bind = next_value(&mut iter, "--bind")?.parse()?,
|
||||
"--chunks" => config.chunks = next_value(&mut iter, "--chunks")?.parse()?,
|
||||
"--first-byte-delay-ms" => {
|
||||
config.first_byte_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--first-byte-delay-ms")?.parse()?)
|
||||
}
|
||||
"--chunk-delay-ms" => {
|
||||
config.chunk_delay =
|
||||
Duration::from_millis(next_value(&mut iter, "--chunk-delay-ms")?.parse()?)
|
||||
}
|
||||
"--payload-bytes" => {
|
||||
config.payload_bytes = next_value(&mut iter, "--payload-bytes")?.parse()?
|
||||
}
|
||||
"--status" => {
|
||||
let status = next_value(&mut iter, "--status")?.parse::<u16>()?;
|
||||
config.status = StatusCode::from_u16(status)?;
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage();
|
||||
std::process::exit(0);
|
||||
}
|
||||
other => {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("unknown argument: {other}"),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn next_value(
|
||||
iter: &mut impl Iterator<Item = String>,
|
||||
flag: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
iter.next().ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
format!("missing value for {flag}"),
|
||||
)
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: cargo run -p aether-testkit --bin mock_openai_upstream -- [--bind 127.0.0.1:18181] [--chunks 8] [--first-byte-delay-ms 0] [--chunk-delay-ms 20] [--payload-bytes 32] [--status 200]"
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,7 @@ pub struct HttpLoadProbeResult {
|
||||
pub mean_ms: u64,
|
||||
pub runtime: BenchmarkRuntimeSnapshot,
|
||||
pub status_counts: BTreeMap<u16, usize>,
|
||||
pub error_counts: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
|
||||
@@ -100,6 +101,7 @@ pub struct MultiUrlHttpLoadProbeResult {
|
||||
pub mean_ms: u64,
|
||||
pub runtime: BenchmarkRuntimeSnapshot,
|
||||
pub status_counts: BTreeMap<u16, usize>,
|
||||
pub error_counts: BTreeMap<String, usize>,
|
||||
}
|
||||
|
||||
pub async fn run_http_load_probe(
|
||||
@@ -129,6 +131,7 @@ pub async fn run_http_load_probe(
|
||||
mean_ms: result.mean_ms,
|
||||
runtime: result.runtime,
|
||||
status_counts: result.status_counts,
|
||||
error_counts: result.error_counts,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,6 +164,7 @@ async fn run_http_load_probe_against_urls(
|
||||
let next_request = Arc::new(AtomicUsize::new(0));
|
||||
let latencies_ms = Arc::new(Mutex::new(Vec::with_capacity(config.total_requests)));
|
||||
let status_counts = Arc::new(Mutex::new(BTreeMap::<u16, usize>::new()));
|
||||
let error_counts = Arc::new(Mutex::new(BTreeMap::<String, usize>::new()));
|
||||
let target_request_counts = Arc::new(Mutex::new(BTreeMap::<String, usize>::new()));
|
||||
let failed_requests = Arc::new(AtomicUsize::new(0));
|
||||
let completed_requests = Arc::new(AtomicUsize::new(0));
|
||||
@@ -171,6 +175,7 @@ async fn run_http_load_probe_against_urls(
|
||||
let next_request = Arc::clone(&next_request);
|
||||
let latencies_ms = Arc::clone(&latencies_ms);
|
||||
let status_counts = Arc::clone(&status_counts);
|
||||
let error_counts = Arc::clone(&error_counts);
|
||||
let target_request_counts = Arc::clone(&target_request_counts);
|
||||
let failed_requests = Arc::clone(&failed_requests);
|
||||
let completed_requests = Arc::clone(&completed_requests);
|
||||
@@ -200,27 +205,33 @@ async fn run_http_load_probe_against_urls(
|
||||
let status = response.status().as_u16();
|
||||
let body_result = match response_mode {
|
||||
HttpLoadProbeResponseMode::HeadersOnly => Ok(()),
|
||||
HttpLoadProbeResponseMode::FullBody => {
|
||||
response.bytes().await.map(|_| ()).map_err(|_| ())
|
||||
}
|
||||
HttpLoadProbeResponseMode::FullBody => response
|
||||
.bytes()
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|err| classify_reqwest_error(&err)),
|
||||
};
|
||||
if body_result.is_ok() {
|
||||
if let Err(error_kind) = body_result {
|
||||
failed_requests.fetch_add(1, Ordering::AcqRel);
|
||||
let mut counts = error_counts.lock().await;
|
||||
*counts.entry(error_kind).or_insert(0) += 1;
|
||||
} else {
|
||||
let mut counts = status_counts.lock().await;
|
||||
*counts.entry(status).or_insert(0) += 1;
|
||||
drop(counts);
|
||||
let mut target_counts = target_request_counts.lock().await;
|
||||
*target_counts.entry(url).or_insert(0) += 1;
|
||||
} else {
|
||||
failed_requests.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
let latency_ms = started_at.elapsed().as_millis() as u64;
|
||||
latencies_ms.lock().await.push(latency_ms);
|
||||
completed_requests.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
Err(_) => {
|
||||
Err(err) => {
|
||||
let latency_ms = started_at.elapsed().as_millis() as u64;
|
||||
latencies_ms.lock().await.push(latency_ms);
|
||||
failed_requests.fetch_add(1, Ordering::AcqRel);
|
||||
let mut counts = error_counts.lock().await;
|
||||
*counts.entry(classify_reqwest_error(&err)).or_insert(0) += 1;
|
||||
completed_requests.fetch_add(1, Ordering::AcqRel);
|
||||
}
|
||||
}
|
||||
@@ -233,6 +244,7 @@ async fn run_http_load_probe_against_urls(
|
||||
}
|
||||
|
||||
let status_counts = status_counts.lock().await.clone();
|
||||
let error_counts = error_counts.lock().await.clone();
|
||||
let target_request_counts = target_request_counts.lock().await.clone();
|
||||
let mut latencies = latencies_ms.lock().await.clone();
|
||||
latencies.sort_unstable();
|
||||
@@ -262,9 +274,32 @@ async fn run_http_load_probe_against_urls(
|
||||
mean_ms,
|
||||
runtime: runtime_sampler.snapshot(),
|
||||
status_counts,
|
||||
error_counts,
|
||||
})
|
||||
}
|
||||
|
||||
fn classify_reqwest_error(err: &reqwest::Error) -> String {
|
||||
if err.is_timeout() {
|
||||
return "timeout".to_string();
|
||||
}
|
||||
if err.is_connect() {
|
||||
return "connect".to_string();
|
||||
}
|
||||
if err.is_body() {
|
||||
return "body".to_string();
|
||||
}
|
||||
if err.is_request() {
|
||||
return "request".to_string();
|
||||
}
|
||||
if err.is_decode() {
|
||||
return "decode".to_string();
|
||||
}
|
||||
if err.is_redirect() {
|
||||
return "redirect".to_string();
|
||||
}
|
||||
"other".to_string()
|
||||
}
|
||||
|
||||
fn build_headers(headers: &BTreeMap<String, String>) -> Result<HeaderMap, String> {
|
||||
let mut result = HeaderMap::new();
|
||||
for (name, value) in headers {
|
||||
|
||||
@@ -4,6 +4,7 @@ use aether_data_contracts::DataLayerError;
|
||||
pub struct UsageRuntimeConfig {
|
||||
pub enabled: bool,
|
||||
pub queue_terminal_events: bool,
|
||||
pub queue_lifecycle_events: bool,
|
||||
pub stream_key: String,
|
||||
pub consumer_group: String,
|
||||
pub dlq_stream_key: String,
|
||||
@@ -20,14 +21,15 @@ impl Default for UsageRuntimeConfig {
|
||||
Self {
|
||||
enabled: false,
|
||||
queue_terminal_events: false,
|
||||
queue_lifecycle_events: false,
|
||||
stream_key: "usage:events".to_string(),
|
||||
consumer_group: "usage_consumers".to_string(),
|
||||
dlq_stream_key: "usage:events:dlq".to_string(),
|
||||
stream_maxlen: 2_000,
|
||||
consumer_batch_size: 200,
|
||||
stream_maxlen: 200_000,
|
||||
consumer_batch_size: 500,
|
||||
consumer_block_ms: 500,
|
||||
reclaim_idle_ms: 30_000,
|
||||
reclaim_count: 200,
|
||||
reclaim_count: 500,
|
||||
reclaim_interval_ms: 5_000,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,12 @@ pub fn build_upsert_usage_record_from_event(
|
||||
event: &UsageEvent,
|
||||
) -> Result<UpsertUsageRecord, DataLayerError> {
|
||||
let (status, billing_status) = lifecycle_status_and_billing(event.event_type);
|
||||
let finalized_at_unix_secs = match event.event_type {
|
||||
UsageEventType::Pending | UsageEventType::Streaming => None,
|
||||
UsageEventType::Completed | UsageEventType::Failed | UsageEventType::Cancelled => {
|
||||
Some(event.timestamp_ms / 1_000)
|
||||
}
|
||||
};
|
||||
let mut data = event.data.clone();
|
||||
data.request_metadata = attach_provider_request_body_metadata(
|
||||
data.request_metadata,
|
||||
@@ -130,7 +136,7 @@ pub fn build_upsert_usage_record_from_event(
|
||||
},
|
||||
),
|
||||
request_metadata: sanitize_usage_request_metadata(data.request_metadata),
|
||||
finalized_at_unix_secs: Some(now_unix_secs),
|
||||
finalized_at_unix_secs,
|
||||
created_at_unix_ms: Some(now_unix_secs),
|
||||
updated_at_unix_secs: now_unix_secs,
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use aether_contracts::ExecutionTelemetry;
|
||||
use aether_data_contracts::repository::usage::UpsertUsageRecord;
|
||||
use aether_data_contracts::DataLayerError;
|
||||
use aether_runtime_state::RuntimeQueueStore;
|
||||
use async_trait::async_trait;
|
||||
@@ -11,12 +11,12 @@ use tracing::warn;
|
||||
|
||||
use crate::executor::spawn_on_usage_background_runtime;
|
||||
use crate::{
|
||||
apply_usage_body_capture_policy_to_event, apply_usage_body_capture_policy_to_record,
|
||||
build_stream_terminal_usage_seed, build_sync_terminal_usage_seed,
|
||||
build_terminal_usage_event_from_seed, build_upsert_usage_record_from_event,
|
||||
build_usage_queue_worker, settle_usage_if_needed, LifecycleUsageSeed,
|
||||
StreamTerminalUsagePayloadSeed, SyncTerminalUsagePayloadSeed, TerminalUsageContextSeed,
|
||||
UsageEvent, UsageQueue, UsageRecordWriter, UsageRuntimeConfig, UsageSettlementWriter,
|
||||
apply_usage_body_capture_policy_to_event, build_stream_terminal_usage_seed,
|
||||
build_sync_terminal_usage_seed, build_terminal_usage_event_from_seed,
|
||||
build_upsert_usage_record_from_event, build_usage_queue_worker, settle_usage_if_needed,
|
||||
LifecycleUsageSeed, StreamTerminalUsagePayloadSeed, SyncTerminalUsagePayloadSeed,
|
||||
TerminalUsageContextSeed, UsageEvent, UsageQueue, UsageRecordWriter, UsageRuntimeConfig,
|
||||
UsageSettlementWriter,
|
||||
};
|
||||
|
||||
#[async_trait]
|
||||
@@ -76,8 +76,11 @@ pub trait UsageRuntimeAccess:
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UsageRuntime {
|
||||
config: UsageRuntimeConfig,
|
||||
body_policy_cache: Arc<tokio::sync::Mutex<Option<(Instant, UsageBodyCapturePolicy)>>>,
|
||||
}
|
||||
|
||||
const USAGE_BODY_CAPTURE_POLICY_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
impl Default for UsageRuntime {
|
||||
fn default() -> Self {
|
||||
Self::disabled()
|
||||
@@ -88,12 +91,16 @@ impl UsageRuntime {
|
||||
pub fn disabled() -> Self {
|
||||
Self {
|
||||
config: UsageRuntimeConfig::disabled(),
|
||||
body_policy_cache: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn new(config: UsageRuntimeConfig) -> Result<Self, DataLayerError> {
|
||||
config.validate()?;
|
||||
Ok(Self { config })
|
||||
Ok(Self {
|
||||
config,
|
||||
body_policy_cache: Arc::new(tokio::sync::Mutex::new(None)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
@@ -105,7 +112,7 @@ impl UsageRuntime {
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
self.is_enabled()
|
||||
&& self.config.queue_terminal_events
|
||||
&& (self.config.queue_terminal_events || self.config.queue_lifecycle_events)
|
||||
&& data.has_usage_writer()
|
||||
&& data.has_usage_worker_queue()
|
||||
}
|
||||
@@ -129,30 +136,25 @@ impl UsageRuntime {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let runtime = self.clone();
|
||||
let data = T::clone(data);
|
||||
let request_id = seed.request_id.clone();
|
||||
spawn_on_usage_background_runtime(boxed_usage_task(async move {
|
||||
let now_unix_secs = now_unix_secs();
|
||||
match build_pending_usage_record_offthread(seed, now_unix_secs).await {
|
||||
Ok(mut record) => {
|
||||
apply_body_capture_policy_to_record_from_data(&data, &mut record).await;
|
||||
if let Err(err) = data.upsert_usage_record(record).await {
|
||||
warn!(
|
||||
event_name = "usage_pending_record_failed",
|
||||
log_type = "event",
|
||||
request_id = %request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to record sync pending usage"
|
||||
);
|
||||
}
|
||||
match build_pending_usage_event_offthread(seed, now_unix_secs).await {
|
||||
Ok(mut event) => {
|
||||
runtime
|
||||
.apply_body_capture_policy_from_data(&data, &mut event)
|
||||
.await;
|
||||
runtime.enqueue_or_write_lifecycle(&data, event).await;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_pending_build_failed",
|
||||
event_name = "usage_pending_event_build_failed",
|
||||
log_type = "event",
|
||||
request_id = %request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to build sync pending usage"
|
||||
"usage runtime failed to build sync pending usage event"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -171,39 +173,29 @@ impl UsageRuntime {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
let runtime = self.clone();
|
||||
let data = T::clone(data);
|
||||
let seed = seed.clone();
|
||||
let telemetry = telemetry.cloned();
|
||||
let request_id = seed.request_id.clone();
|
||||
spawn_on_usage_background_runtime(boxed_usage_task(async move {
|
||||
let now_unix_secs = now_unix_secs();
|
||||
match build_streaming_usage_record_offthread(
|
||||
seed,
|
||||
status_code,
|
||||
telemetry,
|
||||
now_unix_secs,
|
||||
)
|
||||
.await
|
||||
match build_streaming_usage_event_offthread(seed, status_code, telemetry, now_unix_secs)
|
||||
.await
|
||||
{
|
||||
Ok(mut record) => {
|
||||
apply_body_capture_policy_to_record_from_data(&data, &mut record).await;
|
||||
if let Err(err) = data.upsert_usage_record(record).await {
|
||||
warn!(
|
||||
event_name = "usage_stream_record_failed",
|
||||
log_type = "event",
|
||||
request_id = %request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to record stream usage"
|
||||
);
|
||||
}
|
||||
Ok(mut event) => {
|
||||
runtime
|
||||
.apply_body_capture_policy_from_data(&data, &mut event)
|
||||
.await;
|
||||
runtime.enqueue_or_write_lifecycle(&data, event).await;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_stream_build_failed",
|
||||
event_name = "usage_stream_event_build_failed",
|
||||
log_type = "event",
|
||||
request_id = %request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to build stream usage"
|
||||
"usage runtime failed to build stream usage event"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -227,7 +219,9 @@ impl UsageRuntime {
|
||||
spawn_on_usage_background_runtime(boxed_usage_task(async move {
|
||||
match build_sync_terminal_usage_event_offthread(context_seed, payload_seed).await {
|
||||
Ok(mut event) => {
|
||||
apply_body_capture_policy_from_data(&data, &mut event).await;
|
||||
runtime
|
||||
.apply_body_capture_policy_from_data(&data, &mut event)
|
||||
.await;
|
||||
if let Err(err) = data.enrich_usage_event(&mut event).await {
|
||||
warn!(
|
||||
event_name = "usage_sync_terminal_billing_enrichment_failed",
|
||||
@@ -272,7 +266,9 @@ impl UsageRuntime {
|
||||
.await
|
||||
{
|
||||
Ok(mut event) => {
|
||||
apply_body_capture_policy_from_data(&data, &mut event).await;
|
||||
runtime
|
||||
.apply_body_capture_policy_from_data(&data, &mut event)
|
||||
.await;
|
||||
if let Err(err) = data.enrich_usage_event(&mut event).await {
|
||||
warn!(
|
||||
event_name = "usage_stream_terminal_billing_enrichment_failed",
|
||||
@@ -318,7 +314,8 @@ impl UsageRuntime {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
apply_body_capture_policy_from_data(data, &mut event).await;
|
||||
self.apply_body_capture_policy_from_data(data, &mut event)
|
||||
.await;
|
||||
if let Err(err) = data.enrich_usage_event(&mut event).await {
|
||||
warn!(
|
||||
event_name = "usage_terminal_billing_enrichment_failed",
|
||||
@@ -338,7 +335,8 @@ impl UsageRuntime {
|
||||
if !self.is_enabled() {
|
||||
return;
|
||||
}
|
||||
apply_body_capture_policy_from_data(data, &mut event).await;
|
||||
self.apply_body_capture_policy_from_data(data, &mut event)
|
||||
.await;
|
||||
if let Err(err) = data.enrich_usage_event(&mut event).await {
|
||||
warn!(
|
||||
event_name = "usage_terminal_billing_enrichment_failed",
|
||||
@@ -348,33 +346,106 @@ impl UsageRuntime {
|
||||
"usage runtime failed to enrich terminal usage event with billing"
|
||||
);
|
||||
}
|
||||
self.write_terminal_direct(data, &event).await;
|
||||
self.write_event_direct(data, &event).await;
|
||||
}
|
||||
|
||||
async fn apply_body_capture_policy_from_data<T>(&self, data: &T, event: &mut UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
match self.cached_body_capture_policy(data).await {
|
||||
Ok(policy) => apply_usage_body_capture_policy_to_event(policy, event),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_body_capture_policy_read_failed",
|
||||
log_type = "event",
|
||||
request_id = %event.request_id,
|
||||
fallback = "default",
|
||||
error = %err,
|
||||
"usage runtime failed to read body capture policy; keeping default capture"
|
||||
);
|
||||
apply_usage_body_capture_policy_to_event(UsageBodyCapturePolicy::default(), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn body_capture_policy_for<T>(
|
||||
&self,
|
||||
data: &T,
|
||||
) -> Result<UsageBodyCapturePolicy, DataLayerError>
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
self.cached_body_capture_policy(data).await
|
||||
}
|
||||
|
||||
async fn cached_body_capture_policy<T>(
|
||||
&self,
|
||||
data: &T,
|
||||
) -> Result<UsageBodyCapturePolicy, DataLayerError>
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
let mut cache = self.body_policy_cache.lock().await;
|
||||
if let Some((cached_at, policy)) = cache.as_ref() {
|
||||
if cached_at.elapsed() <= USAGE_BODY_CAPTURE_POLICY_CACHE_TTL {
|
||||
return Ok(*policy);
|
||||
}
|
||||
}
|
||||
let policy = data.body_capture_policy().await?;
|
||||
*cache = Some((Instant::now(), policy));
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
async fn enqueue_or_write_terminal<T>(&self, data: &T, event: UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
if self.config.queue_terminal_events {
|
||||
self.enqueue_or_write_event(data, event, "terminal", self.config.queue_terminal_events)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn enqueue_or_write_lifecycle<T>(&self, data: &T, event: UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
self.enqueue_or_write_event(data, event, "lifecycle", self.config.queue_lifecycle_events)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn enqueue_or_write_event<T>(
|
||||
&self,
|
||||
data: &T,
|
||||
event: UsageEvent,
|
||||
event_phase: &'static str,
|
||||
queue_enabled: bool,
|
||||
) where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
if queue_enabled {
|
||||
if let Some(runner) = data.usage_worker_queue() {
|
||||
match UsageQueue::new(runner, self.config.clone()) {
|
||||
Ok(queue) => match queue.enqueue(&event).await {
|
||||
Ok(_) => return,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_terminal_enqueue_failed",
|
||||
event_name = "usage_event_enqueue_failed",
|
||||
log_type = "event",
|
||||
event_phase,
|
||||
usage_event_type = ?event.event_type,
|
||||
request_id = %event.request_id,
|
||||
fallback = "direct_write",
|
||||
error = %err,
|
||||
"usage runtime failed to enqueue terminal usage event; falling back to direct write"
|
||||
"usage runtime failed to enqueue usage event; falling back to direct write"
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_terminal_queue_init_failed",
|
||||
event_name = "usage_event_queue_init_failed",
|
||||
log_type = "event",
|
||||
event_phase,
|
||||
usage_event_type = ?event.event_type,
|
||||
request_id = %event.request_id,
|
||||
fallback = "direct_write",
|
||||
error = %err,
|
||||
@@ -385,10 +456,10 @@ impl UsageRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
self.write_terminal_direct(data, &event).await;
|
||||
self.write_event_direct(data, &event).await;
|
||||
}
|
||||
|
||||
async fn write_terminal_direct<T>(&self, data: &T, event: &UsageEvent)
|
||||
async fn write_event_direct<T>(&self, data: &T, event: &UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
@@ -408,46 +479,48 @@ impl UsageRuntime {
|
||||
Ok(None) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_terminal_upsert_failed",
|
||||
event_name = "usage_event_upsert_failed",
|
||||
log_type = "event",
|
||||
usage_event_type = ?event.event_type,
|
||||
request_id = %event.request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to upsert terminal usage directly"
|
||||
"usage runtime failed to upsert usage event directly"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_terminal_upsert_build_failed",
|
||||
event_name = "usage_event_upsert_build_failed",
|
||||
log_type = "event",
|
||||
usage_event_type = ?event.event_type,
|
||||
request_id = %event.request_id,
|
||||
error = %err,
|
||||
"usage runtime failed to build terminal usage upsert"
|
||||
"usage runtime failed to build usage event upsert"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_pending_usage_record_offthread(
|
||||
async fn build_pending_usage_event_offthread(
|
||||
seed: LifecycleUsageSeed,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<UpsertUsageRecord, DataLayerError> {
|
||||
) -> Result<UsageEvent, DataLayerError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::write::build_pending_usage_record_from_owned_seed(seed, now_unix_secs)
|
||||
crate::write::build_pending_usage_event_from_owned_seed(seed, now_unix_secs)
|
||||
})
|
||||
.await
|
||||
.map_err(join_error_to_data_layer)?
|
||||
}
|
||||
|
||||
async fn build_streaming_usage_record_offthread(
|
||||
async fn build_streaming_usage_event_offthread(
|
||||
seed: LifecycleUsageSeed,
|
||||
status_code: u16,
|
||||
telemetry: Option<ExecutionTelemetry>,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<UpsertUsageRecord, DataLayerError> {
|
||||
) -> Result<UsageEvent, DataLayerError> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::write::build_streaming_usage_record_from_owned_seed(
|
||||
crate::write::build_streaming_usage_event_from_owned_seed(
|
||||
seed,
|
||||
status_code,
|
||||
telemetry,
|
||||
@@ -492,46 +565,6 @@ fn join_error_to_data_layer(err: tokio::task::JoinError) -> DataLayerError {
|
||||
DataLayerError::UnexpectedValue(format!("usage builder task join failed: {err}"))
|
||||
}
|
||||
|
||||
async fn apply_body_capture_policy_from_data<T>(data: &T, event: &mut UsageEvent)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
match data.body_capture_policy().await {
|
||||
Ok(policy) => apply_usage_body_capture_policy_to_event(policy, event),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_body_capture_policy_read_failed",
|
||||
log_type = "event",
|
||||
request_id = %event.request_id,
|
||||
fallback = "default",
|
||||
error = %err,
|
||||
"usage runtime failed to read body capture policy; keeping default capture"
|
||||
);
|
||||
apply_usage_body_capture_policy_to_event(UsageBodyCapturePolicy::default(), event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_body_capture_policy_to_record_from_data<T>(data: &T, record: &mut UpsertUsageRecord)
|
||||
where
|
||||
T: UsageRuntimeAccess,
|
||||
{
|
||||
match data.body_capture_policy().await {
|
||||
Ok(policy) => apply_usage_body_capture_policy_to_record(policy, record),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
event_name = "usage_body_capture_policy_read_failed",
|
||||
log_type = "event",
|
||||
request_id = %record.request_id,
|
||||
fallback = "default",
|
||||
error = %err,
|
||||
"usage runtime failed to read body capture policy; keeping default capture"
|
||||
);
|
||||
apply_usage_body_capture_policy_to_record(UsageBodyCapturePolicy::default(), record);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn boxed_usage_task<F>(task: F) -> Pin<Box<dyn Future<Output = ()> + Send>>
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
@@ -548,8 +581,10 @@ fn now_unix_secs() -> u64 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use aether_contracts::{ExecutionPlan, RequestBody};
|
||||
use aether_data_contracts::repository::settlement::{
|
||||
StoredUsageSettlement, UsageSettlementInput,
|
||||
};
|
||||
@@ -558,6 +593,7 @@ mod tests {
|
||||
use aether_runtime_state::{MemoryRuntimeStateConfig, RuntimeQueueStore, RuntimeState};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use super::{
|
||||
UsageBillingEventEnricher, UsageBodyCapturePolicy, UsageRequestRecordLevel,
|
||||
@@ -565,8 +601,9 @@ mod tests {
|
||||
};
|
||||
use crate::worker::ManualProxyNodeCounter;
|
||||
use crate::{
|
||||
apply_usage_body_capture_policy_to_event, UsageEvent, UsageEventData, UsageEventType,
|
||||
UsageRecordWriter, UsageRuntime, UsageRuntimeConfig, UsageSettlementWriter,
|
||||
apply_usage_body_capture_policy_to_event, build_lifecycle_usage_seed, UsageEvent,
|
||||
UsageEventData, UsageEventType, UsageQueue, UsageRecordWriter, UsageRuntime,
|
||||
UsageRuntimeConfig, UsageSettlementWriter,
|
||||
};
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -579,6 +616,12 @@ mod tests {
|
||||
queue: Arc<dyn RuntimeQueueStore>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CloneQueueConfiguredUsageStore {
|
||||
records: Arc<Mutex<Vec<UpsertUsageRecord>>>,
|
||||
queue: Arc<dyn RuntimeQueueStore>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsageRecordWriter for NoRedisUsageStore {
|
||||
async fn upsert_usage_record(
|
||||
@@ -696,6 +739,65 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsageRecordWriter for CloneQueueConfiguredUsageStore {
|
||||
async fn upsert_usage_record(
|
||||
&self,
|
||||
record: UpsertUsageRecord,
|
||||
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
|
||||
self.records.lock().expect("records lock").push(record);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsageSettlementWriter for CloneQueueConfiguredUsageStore {
|
||||
fn has_usage_settlement_writer(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
async fn settle_usage(
|
||||
&self,
|
||||
_input: UsageSettlementInput,
|
||||
) -> Result<Option<StoredUsageSettlement>, DataLayerError> {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UsageBillingEventEnricher for CloneQueueConfiguredUsageStore {
|
||||
async fn enrich_usage_event(&self, _event: &mut UsageEvent) -> Result<(), DataLayerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ManualProxyNodeCounter for CloneQueueConfiguredUsageStore {
|
||||
async fn increment_manual_proxy_node_requests(
|
||||
&self,
|
||||
_node_id: &str,
|
||||
_total_delta: i64,
|
||||
_failed_delta: i64,
|
||||
_latency_ms: Option<i64>,
|
||||
) -> Result<(), DataLayerError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl UsageRuntimeAccess for CloneQueueConfiguredUsageStore {
|
||||
fn has_usage_writer(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn has_usage_worker_queue(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn usage_worker_queue(&self) -> Option<Arc<dyn RuntimeQueueStore>> {
|
||||
Some(Arc::clone(&self.queue))
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_usage_without_redis_writes_directly_to_usage_repository() {
|
||||
let runtime = UsageRuntime::new(UsageRuntimeConfig {
|
||||
@@ -762,6 +864,75 @@ mod tests {
|
||||
assert_eq!(records[0].status_code, Some(503));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_usage_uses_lifecycle_queue_when_enabled() {
|
||||
let config = UsageRuntimeConfig {
|
||||
enabled: true,
|
||||
queue_lifecycle_events: true,
|
||||
stream_key: "usage:events:test:pending".to_string(),
|
||||
consumer_group: "usage_consumers_test_pending".to_string(),
|
||||
consumer_block_ms: 1,
|
||||
..UsageRuntimeConfig::default()
|
||||
};
|
||||
let queue_runner: Arc<dyn RuntimeQueueStore> =
|
||||
Arc::new(RuntimeState::memory(MemoryRuntimeStateConfig::default()));
|
||||
let queue = UsageQueue::new(Arc::clone(&queue_runner), config.clone())
|
||||
.expect("usage queue should build");
|
||||
queue
|
||||
.ensure_consumer_group()
|
||||
.await
|
||||
.expect("consumer group should initialize");
|
||||
let store = CloneQueueConfiguredUsageStore {
|
||||
records: Arc::new(Mutex::new(Vec::new())),
|
||||
queue: queue_runner,
|
||||
};
|
||||
let runtime = UsageRuntime::new(config).expect("usage runtime should build");
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-lifecycle-queue-pending-1".to_string(),
|
||||
candidate_id: Some("cand-lifecycle-queue-pending-1".to_string()),
|
||||
provider_name: Some("openai".to_string()),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://example.com/v1/responses".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5"})),
|
||||
stream: false,
|
||||
client_api_format: "openai:responses".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
|
||||
runtime.record_pending(&store, build_lifecycle_usage_seed(&plan, None));
|
||||
|
||||
for _ in 0..50 {
|
||||
let entries = queue
|
||||
.read_group("usage-test-consumer")
|
||||
.await
|
||||
.expect("queue read should succeed");
|
||||
if let Some(entry) = entries.into_iter().next() {
|
||||
let event = UsageEvent::from_stream_fields(&entry.fields)
|
||||
.expect("queued usage event should parse");
|
||||
assert_eq!(event.event_type, UsageEventType::Pending);
|
||||
assert_eq!(event.request_id, "req-lifecycle-queue-pending-1");
|
||||
assert!(
|
||||
store.records.lock().expect("records lock").is_empty(),
|
||||
"pending lifecycle event should not write directly when queue succeeds"
|
||||
);
|
||||
return;
|
||||
}
|
||||
sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
panic!("pending lifecycle usage event was not enqueued");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_request_record_level_strips_body_capture_but_preserves_derived_fields() {
|
||||
let mut event = UsageEvent::new(
|
||||
|
||||
@@ -367,6 +367,17 @@ pub(crate) fn build_pending_usage_record_from_owned_seed(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_pending_usage_event_from_owned_seed(
|
||||
seed: LifecycleUsageSeed,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Result<UsageEvent, DataLayerError> {
|
||||
let record = build_pending_usage_record_from_owned_seed(seed, updated_at_unix_secs)?;
|
||||
Ok(build_lifecycle_usage_event_from_record(
|
||||
record,
|
||||
UsageEventType::Pending,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn build_streaming_usage_record(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
@@ -432,6 +443,101 @@ pub(crate) fn build_streaming_usage_record_from_owned_seed(
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn build_streaming_usage_event_from_owned_seed(
|
||||
seed: LifecycleUsageSeed,
|
||||
status_code: u16,
|
||||
telemetry: Option<ExecutionTelemetry>,
|
||||
updated_at_unix_secs: u64,
|
||||
) -> Result<UsageEvent, DataLayerError> {
|
||||
let record = build_streaming_usage_record_from_owned_seed(
|
||||
seed,
|
||||
status_code,
|
||||
telemetry,
|
||||
updated_at_unix_secs,
|
||||
)?;
|
||||
Ok(build_lifecycle_usage_event_from_record(
|
||||
record,
|
||||
UsageEventType::Streaming,
|
||||
))
|
||||
}
|
||||
|
||||
fn build_lifecycle_usage_event_from_record(
|
||||
record: UpsertUsageRecord,
|
||||
event_type: UsageEventType,
|
||||
) -> UsageEvent {
|
||||
UsageEvent {
|
||||
event_type,
|
||||
request_id: record.request_id,
|
||||
timestamp_ms: record.updated_at_unix_secs.saturating_mul(1_000),
|
||||
data: UsageEventData {
|
||||
user_id: record.user_id,
|
||||
api_key_id: record.api_key_id,
|
||||
username: record.username,
|
||||
api_key_name: record.api_key_name,
|
||||
provider_name: record.provider_name,
|
||||
model: record.model,
|
||||
target_model: record.target_model,
|
||||
provider_id: record.provider_id,
|
||||
provider_endpoint_id: record.provider_endpoint_id,
|
||||
provider_api_key_id: record.provider_api_key_id,
|
||||
request_type: record.request_type,
|
||||
api_format: record.api_format,
|
||||
api_family: record.api_family,
|
||||
endpoint_kind: record.endpoint_kind,
|
||||
endpoint_api_format: record.endpoint_api_format,
|
||||
provider_api_family: record.provider_api_family,
|
||||
provider_endpoint_kind: record.provider_endpoint_kind,
|
||||
has_format_conversion: record.has_format_conversion,
|
||||
is_stream: record.is_stream,
|
||||
input_tokens: record.input_tokens,
|
||||
output_tokens: record.output_tokens,
|
||||
total_tokens: record.total_tokens,
|
||||
cache_creation_input_tokens: record.cache_creation_input_tokens,
|
||||
cache_creation_ephemeral_5m_input_tokens: record
|
||||
.cache_creation_ephemeral_5m_input_tokens,
|
||||
cache_creation_ephemeral_1h_input_tokens: record
|
||||
.cache_creation_ephemeral_1h_input_tokens,
|
||||
cache_read_input_tokens: record.cache_read_input_tokens,
|
||||
cache_creation_cost_usd: record.cache_creation_cost_usd,
|
||||
cache_read_cost_usd: record.cache_read_cost_usd,
|
||||
output_price_per_1m: record.output_price_per_1m,
|
||||
total_cost_usd: record.total_cost_usd,
|
||||
actual_total_cost_usd: record.actual_total_cost_usd,
|
||||
status_code: record.status_code,
|
||||
error_message: record.error_message,
|
||||
error_category: record.error_category,
|
||||
response_time_ms: record.response_time_ms,
|
||||
first_byte_time_ms: record.first_byte_time_ms,
|
||||
request_headers: record.request_headers,
|
||||
request_body: record.request_body,
|
||||
request_body_ref: record.request_body_ref,
|
||||
request_body_state: record.request_body_state,
|
||||
provider_request_headers: record.provider_request_headers,
|
||||
provider_request_body: record.provider_request_body,
|
||||
provider_request_body_ref: record.provider_request_body_ref,
|
||||
provider_request_body_state: record.provider_request_body_state,
|
||||
response_headers: record.response_headers,
|
||||
response_body: record.response_body,
|
||||
response_body_ref: record.response_body_ref,
|
||||
response_body_state: record.response_body_state,
|
||||
client_response_headers: record.client_response_headers,
|
||||
client_response_body: record.client_response_body,
|
||||
client_response_body_ref: record.client_response_body_ref,
|
||||
client_response_body_state: record.client_response_body_state,
|
||||
candidate_id: record.candidate_id,
|
||||
candidate_index: record.candidate_index,
|
||||
key_name: record.key_name,
|
||||
planner_kind: record.planner_kind,
|
||||
route_family: record.route_family,
|
||||
route_kind: record.route_kind,
|
||||
execution_path: record.execution_path,
|
||||
local_execution_runtime_miss_reason: record.local_execution_runtime_miss_reason,
|
||||
request_metadata: record.request_metadata,
|
||||
..UsageEventData::default()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_sync_terminal_usage_event(
|
||||
plan: &ExecutionPlan,
|
||||
report_context: Option<&Value>,
|
||||
@@ -3228,8 +3334,9 @@ fn empty_to_none(value: Option<String>) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
build_pending_usage_record, build_pending_usage_record_from_seed,
|
||||
build_stream_terminal_usage_event, build_streaming_usage_record,
|
||||
build_pending_usage_event_from_owned_seed, build_pending_usage_record,
|
||||
build_pending_usage_record_from_seed, build_stream_terminal_usage_event,
|
||||
build_streaming_usage_event_from_owned_seed, build_streaming_usage_record,
|
||||
build_sync_terminal_usage_event, build_sync_terminal_usage_payload_seed,
|
||||
build_sync_terminal_usage_seed, build_terminal_usage_context_seed,
|
||||
build_terminal_usage_event_from_seed, build_usage_event_data_seed, decode_body_for_storage,
|
||||
@@ -3499,6 +3606,113 @@ mod tests {
|
||||
assert!(body_size.get("provider_over_client").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_usage_event_round_trips_to_lightweight_upsert_record() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-pending-event-1".to_string(),
|
||||
candidate_id: Some("cand-pending-event-1".to_string()),
|
||||
provider_name: Some("Codex Proxy".to_string()),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://example.com/v1/messages".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5.4"})),
|
||||
stream: false,
|
||||
client_api_format: "claude:messages".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
|
||||
let event = build_pending_usage_event_from_owned_seed(
|
||||
super::build_lifecycle_usage_seed(
|
||||
&plan,
|
||||
Some(&json!({
|
||||
"candidate_id": "cand-pending-event-1",
|
||||
"candidate_index": 3,
|
||||
"original_request_body": {"messages": [{"content": "omit me"}]},
|
||||
"provider_request_body": {"input": "omit me too"}
|
||||
})),
|
||||
),
|
||||
1_700_000_020,
|
||||
)
|
||||
.expect("pending usage event should build");
|
||||
let record = build_upsert_usage_record_from_event(&event).expect("record should build");
|
||||
|
||||
assert_eq!(event.event_type, UsageEventType::Pending);
|
||||
assert_eq!(record.request_id, "req-pending-event-1");
|
||||
assert_eq!(record.status, "pending");
|
||||
assert_eq!(record.billing_status, "pending");
|
||||
assert_eq!(record.finalized_at_unix_secs, None);
|
||||
assert_eq!(record.updated_at_unix_secs, 1_700_000_020);
|
||||
assert!(record.request_body.is_none());
|
||||
assert!(record.provider_request_body.is_none());
|
||||
assert_eq!(record.candidate_id.as_deref(), Some("cand-pending-event-1"));
|
||||
assert_eq!(record.candidate_index, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn streaming_usage_event_round_trips_to_lightweight_upsert_record() {
|
||||
let plan = ExecutionPlan {
|
||||
request_id: "req-streaming-event-1".to_string(),
|
||||
candidate_id: Some("cand-streaming-event-1".to_string()),
|
||||
provider_name: Some("Codex Proxy".to_string()),
|
||||
provider_id: "provider-1".to_string(),
|
||||
endpoint_id: "endpoint-1".to_string(),
|
||||
key_id: "key-1".to_string(),
|
||||
method: "POST".to_string(),
|
||||
url: "https://example.com/v1/messages".to_string(),
|
||||
headers: BTreeMap::new(),
|
||||
content_type: Some("application/json".to_string()),
|
||||
content_encoding: None,
|
||||
body: RequestBody::from_json(json!({"model": "gpt-5.4"})),
|
||||
stream: true,
|
||||
client_api_format: "claude:messages".to_string(),
|
||||
provider_api_format: "openai:responses".to_string(),
|
||||
model_name: Some("gpt-5.4".to_string()),
|
||||
proxy: None,
|
||||
transport_profile: None,
|
||||
timeouts: None,
|
||||
};
|
||||
|
||||
let event = build_streaming_usage_event_from_owned_seed(
|
||||
super::build_lifecycle_usage_seed(
|
||||
&plan,
|
||||
Some(&json!({
|
||||
"candidate_id": "cand-streaming-event-1",
|
||||
"candidate_index": 4,
|
||||
"provider_request_body": {"input": "omit me"}
|
||||
})),
|
||||
),
|
||||
200,
|
||||
None,
|
||||
1_700_000_030,
|
||||
)
|
||||
.expect("streaming usage event should build");
|
||||
let record = build_upsert_usage_record_from_event(&event).expect("record should build");
|
||||
|
||||
assert_eq!(event.event_type, UsageEventType::Streaming);
|
||||
assert_eq!(record.request_id, "req-streaming-event-1");
|
||||
assert_eq!(record.status, "streaming");
|
||||
assert_eq!(record.billing_status, "pending");
|
||||
assert_eq!(record.status_code, Some(200));
|
||||
assert_eq!(record.finalized_at_unix_secs, None);
|
||||
assert_eq!(record.updated_at_unix_secs, 1_700_000_030);
|
||||
assert!(record.request_body.is_none());
|
||||
assert!(record.provider_request_body.is_none());
|
||||
assert_eq!(
|
||||
record.candidate_id.as_deref(),
|
||||
Some("cand-streaming-event-1")
|
||||
);
|
||||
assert_eq!(record.candidate_index, Some(4));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_usage_record_preserves_standalone_key_metadata() {
|
||||
let plan = ExecutionPlan {
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
# Aether Gateway DB pressure testing
|
||||
|
||||
目标:验证 6k+ 并发长连接/流式请求时,gateway 不把请求并发线性放大成 DB 连接并发,并确认 DB pool、usage queue、后台维护任务不会成为瓶颈。
|
||||
|
||||
## 1. 预设环境
|
||||
|
||||
生产/压测环境建议显式设置:
|
||||
|
||||
```bash
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS=80
|
||||
export AETHER_GATEWAY_DATA_POSTGRES_MIN_CONNECTIONS=12
|
||||
export AETHER_GATEWAY_MAINTENANCE_POOL_IDLE_RESERVE=8
|
||||
|
||||
export AETHER_GATEWAY_USAGE_QUEUE_TERMINAL_EVENTS=true
|
||||
export AETHER_GATEWAY_USAGE_QUEUE_LIFECYCLE_EVENTS=true
|
||||
export AETHER_GATEWAY_USAGE_QUEUE_STREAM_MAXLEN=200000
|
||||
export AETHER_GATEWAY_USAGE_QUEUE_BATCH_SIZE=500
|
||||
export AETHER_GATEWAY_USAGE_QUEUE_RECLAIM_COUNT=500
|
||||
|
||||
# Pool score DB feedback is rate-limited per provider key to avoid one
|
||||
# synchronous score UPDATE per successful request.
|
||||
export AETHER_GATEWAY_POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_SECS=5
|
||||
export AETHER_GATEWAY_POOL_SCORE_FAILURE_FEEDBACK_MIN_INTERVAL_SECS=1
|
||||
|
||||
# Invalid API keys are cached briefly so repeated bad credentials cannot
|
||||
# linearly amplify into DB lookups. Set 0 to disable during auth debugging.
|
||||
export AETHER_GATEWAY_AUTH_CONTEXT_NEGATIVE_CACHE_TTL_SECS=10
|
||||
```
|
||||
|
||||
SQLite 不适合 6k 并发压测;请使用 Postgres/MySQL 和 Redis runtime backend。
|
||||
|
||||
## 2. Gateway HTTP 压测
|
||||
|
||||
准备请求体:
|
||||
|
||||
```bash
|
||||
cat >/tmp/aether-pressure-request.json <<'JSON'
|
||||
{"model":"gpt-5-mini","messages":[{"role":"user","content":"ping"}],"stream":true}
|
||||
JSON
|
||||
```
|
||||
|
||||
运行 6k 并发(高并发结论必须使用 release;debug 构建会把 CPU/调试开销误判成 planning timeout):
|
||||
|
||||
```bash
|
||||
TARGET_URL=http://127.0.0.1:18080/v1/chat/completions \
|
||||
METRICS_URL=http://127.0.0.1:18080/_gateway/metrics \
|
||||
PRESSURE_METHOD=POST \
|
||||
PRESSURE_REQUESTS=60000 \
|
||||
PRESSURE_CONCURRENCY=6000 \
|
||||
PRESSURE_TIMEOUT_MS=120000 \
|
||||
PRESSURE_BODY_FILE=/tmp/aether-pressure-request.json \
|
||||
AUTH_HEADER='Authorization: Bearer <api-key>' \
|
||||
EXTRA_HEADERS='Content-Type: application/json' \
|
||||
PRESSURE_RESPONSE_MODE=full \
|
||||
PRESSURE_CARGO_PROFILE=release \
|
||||
OUTPUT=/tmp/aether_gateway_pressure_6k.json \
|
||||
tools/pressure/run_gateway_6k_pressure.sh
|
||||
```
|
||||
|
||||
如果压测流式长连接,建议让 probe 读完整响应体,否则客户端拿到 headers 后会立刻断开:
|
||||
|
||||
```bash
|
||||
PRESSURE_RESPONSE_MODE=full
|
||||
```
|
||||
|
||||
## 3. 本地 mock upstream
|
||||
|
||||
没有可承载 6k 并发的真实上游 key 时,先用 testkit 启一个 OpenAI-compatible mock upstream:
|
||||
|
||||
```bash
|
||||
cargo run --release -p aether-testkit --bin mock_openai_upstream -- \
|
||||
--bind 127.0.0.1:18181 \
|
||||
--chunks 8 \
|
||||
--first-byte-delay-ms 0 \
|
||||
--chunk-delay-ms 20 \
|
||||
--payload-bytes 32
|
||||
```
|
||||
|
||||
可直接压 mock 网络栈:
|
||||
|
||||
```bash
|
||||
cat >/tmp/aether-mock-request.json <<'JSON'
|
||||
{"model":"mock-model","messages":[{"role":"user","content":"ping"}],"stream":true}
|
||||
JSON
|
||||
|
||||
cargo run --release -p aether-testkit --bin http_load_probe -- \
|
||||
--url http://127.0.0.1:18181/v1/chat/completions \
|
||||
--method POST \
|
||||
--requests 60000 \
|
||||
--concurrency 6000 \
|
||||
--timeout-ms 120000 \
|
||||
--header 'Content-Type: application/json' \
|
||||
--body-file /tmp/aether-mock-request.json \
|
||||
--response-mode full
|
||||
```
|
||||
|
||||
要做 gateway 端到端压测,把一个本地压测 provider/endpoint 指到
|
||||
`http://127.0.0.1:18181/v1`,provider key 用 dummy 值即可;gateway 侧仍需一个
|
||||
本地 Aether API key,但不再消耗真实上游额度。
|
||||
|
||||
报告重点字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"load": {
|
||||
"throughput_rps": 0,
|
||||
"failed_requests": 0,
|
||||
"error_counts": {},
|
||||
"p95_ms": 0,
|
||||
"p99_ms": 0
|
||||
},
|
||||
"metrics": {
|
||||
"db_pool_max_checked_out": 0,
|
||||
"db_pool_min_idle": 0,
|
||||
"db_pool_max_usage_basis_points": 0,
|
||||
"db_pool_pressure_samples": 0,
|
||||
"gateway_requests_max_rejected_total": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
### 本地全链路 release 基线(mock upstream)
|
||||
|
||||
本地全链路压测固定为:release gateway + release mock upstream + 本地 Postgres/Redis + 临时 Aether API key/provider/model。
|
||||
不要把 6k 长连接理解为 6k DB 连接;目标是 6k 前端连接下 DB pool 维持在几十级。
|
||||
|
||||
最近可复现基线:
|
||||
|
||||
| 场景 | 结果 | throughput | p50 | p95 | p99 | DB pool | mock in-flight |
|
||||
| --- | --- | ---: | ---: | ---: | ---: | --- | ---: |
|
||||
| 1000 req / 100 conc | 1000x 200 / 0 fail | 211 rps | 209ms | 729ms | 1171ms | max checked out 33/48 | - |
|
||||
| 6000 req / 6000 conc | 6000x 200 / 0 fail, `error_counts={}` | 262 rps | 20198ms | 22496ms | 22748ms | max checked out 48/48, pressure samples 7 | 396 |
|
||||
|
||||
6000/6000 下实际打开约 6k FD,说明客户端长连接链路有效。剩余主要同步 DB 写入为每成功请求 1 条 terminal `request_candidates`;如需测纯转发极限,可临时把 `AETHER_GATEWAY_REQUEST_CANDIDATE_PERSISTENCE=none` 与 terminal 模式对照。
|
||||
|
||||
常用本地命令(不要打印 API key):
|
||||
|
||||
```bash
|
||||
source /tmp/aether_local_env.sh
|
||||
KEY=$(cat /tmp/aether_fullchain_api_key)
|
||||
CARGO_TARGET_DIR=/tmp/aether-release-pressure \
|
||||
TARGET_URL=http://127.0.0.1:8088/v1/chat/completions \
|
||||
METRICS_URL=http://127.0.0.1:8088/_gateway/metrics \
|
||||
PRESSURE_METHOD=POST \
|
||||
PRESSURE_REQUESTS=6000 \
|
||||
PRESSURE_CONCURRENCY=6000 \
|
||||
PRESSURE_TIMEOUT_MS=120000 \
|
||||
PRESSURE_SAMPLE_INTERVAL_MS=250 \
|
||||
PRESSURE_BODY_FILE=/tmp/aether-mock-request.json \
|
||||
PRESSURE_RESPONSE_MODE=full \
|
||||
PRESSURE_CARGO_PROFILE=release \
|
||||
AUTH_HEADER="Authorization: Bearer ${KEY}" \
|
||||
EXTRA_HEADERS='Content-Type: application/json' \
|
||||
OUTPUT=/tmp/aether_gateway_release_6000_6000_full.json \
|
||||
tools/pressure/run_gateway_6k_pressure.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose exec -T -e PGPASSWORD="$DB_PASSWORD" postgres psql -U postgres -d aether -P pager=off -c "
|
||||
SELECT calls, round(total_exec_time::numeric,1) total_ms,
|
||||
round(mean_exec_time::numeric,3) mean_ms, rows,
|
||||
left(regexp_replace(query, '\s+', ' ', 'g'), 280) query
|
||||
FROM pg_stat_statements
|
||||
ORDER BY calls DESC
|
||||
LIMIT 60;"
|
||||
```
|
||||
|
||||
## 4. 判定标准
|
||||
|
||||
优先看这些信号:
|
||||
|
||||
- `failed_requests == 0` 或仅包含预期的上游错误。
|
||||
- `db_pool_max_checked_out` 不应接近 `AETHER_GATEWAY_DATA_POSTGRES_MAX_CONNECTIONS`。
|
||||
- `db_pool_min_idle` 在大部分采样中应高于 idle reserve。
|
||||
- `db_pool_max_usage_basis_points < 8000` 比较健康;持续 `>9000` 表示 DB pool 或 SQL 写入已接近瓶颈。
|
||||
- `db_pool_pressure_samples` 可以短暂出现,但不应贯穿压测全程。
|
||||
- `gateway_requests_max_rejected_total` 不应增长,除非有意测试 admission limit。
|
||||
|
||||
## 5. DB 热点写入专项压测
|
||||
|
||||
这些 testkit 场景会启动临时 Postgres,适合回归验证 counter/settlement 热点锁竞争:
|
||||
|
||||
```bash
|
||||
cargo run -p aether-testkit --bin usage_counter_hotspot_baseline -- \
|
||||
--requests 20000 --concurrency 1000 \
|
||||
--flush-interval-ms 50 --monitor-interval-ms 20 \
|
||||
--output /tmp/aether_usage_counter_20000_1000.json
|
||||
|
||||
cargo run -p aether-testkit --bin usage_settlement_hotspot_baseline -- \
|
||||
--requests 20000 --concurrency 1000 \
|
||||
--flush-interval-ms 50 --monitor-interval-ms 20 \
|
||||
--output /tmp/aether_usage_settlement_20000_1000.json
|
||||
|
||||
cargo run -p aether-testkit --bin usage_aux_counter_hotspot_baseline -- \
|
||||
--requests 20000 --concurrency 1000 \
|
||||
--flush-interval-ms 50 --monitor-interval-ms 20 \
|
||||
--output /tmp/aether_usage_aux_counter_20000_1000.json
|
||||
```
|
||||
|
||||
关注:
|
||||
|
||||
- `failed_requests`
|
||||
- `throughput_rps`
|
||||
- `p95_ms`
|
||||
- `lock_monitor.max_*_update_waiters`
|
||||
- `lock_monitor.max_oldest_lock_wait_ms`
|
||||
|
||||
定向 update waiter 持续大于 0,说明某类 counter/settlement 仍有热点行锁竞争,需要继续分桶或延迟聚合。
|
||||
|
||||
## 6. 本地回归基线
|
||||
|
||||
最近一次本地临时 Postgres 回归(`requests=60000, concurrency=6000, max_connections=64`):
|
||||
|
||||
| suite | throughput | failed | p95 | 定向 update waiters |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| `usage_settlement_hotspot_baseline` | 5452 rps | 0 | 740ms | usage 10 / wallet 0 / provider 0 |
|
||||
| `usage_counter_hotspot_baseline` | 1358 rps | 0 | 1865ms | api_key 0 / provider_key 0 / model 0 / provider 0 |
|
||||
| `usage_aux_counter_hotspot_baseline` | 1899 rps | 0 | 785ms | proxy 0 / management_token 0 / api_key 0 |
|
||||
|
||||
这些是 DB 热点写入专项结果,不等价于完整 gateway 端到端 6k 流式压测;完整压测仍需使用第 2 节的 gateway URL、真实 API key、Redis runtime backend 与目标模型请求体。
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Gateway DB pressure probe.
|
||||
#
|
||||
# Required:
|
||||
# TARGET_URL=http://127.0.0.1:18080/v1/chat/completions
|
||||
# METRICS_URL=http://127.0.0.1:18080/_gateway/metrics
|
||||
#
|
||||
# Common optional settings:
|
||||
# PRESSURE_REQUESTS=60000
|
||||
# PRESSURE_CONCURRENCY=6000
|
||||
# PRESSURE_TIMEOUT_MS=120000
|
||||
# PRESSURE_SAMPLE_INTERVAL_MS=500
|
||||
# PRESSURE_METHOD=POST
|
||||
# PRESSURE_BODY='{"model":"...","messages":[...],"stream":true}'
|
||||
# PRESSURE_BODY_FILE=/tmp/request.json
|
||||
# PRESSURE_RESPONSE_MODE=full
|
||||
# PRESSURE_CARGO_PROFILE=release
|
||||
# AUTH_HEADER='Authorization: Bearer sk-...'
|
||||
# EXTRA_HEADERS=$'Content-Type: application/json\nX-Foo: bar'
|
||||
# OUTPUT=/tmp/aether_gateway_pressure_6k.json
|
||||
|
||||
TARGET_URL="${TARGET_URL:?TARGET_URL is required}"
|
||||
METRICS_URL="${METRICS_URL:?METRICS_URL is required}"
|
||||
PRESSURE_REQUESTS="${PRESSURE_REQUESTS:-60000}"
|
||||
PRESSURE_CONCURRENCY="${PRESSURE_CONCURRENCY:-6000}"
|
||||
PRESSURE_TIMEOUT_MS="${PRESSURE_TIMEOUT_MS:-120000}"
|
||||
PRESSURE_SAMPLE_INTERVAL_MS="${PRESSURE_SAMPLE_INTERVAL_MS:-500}"
|
||||
PRESSURE_METHOD="${PRESSURE_METHOD:-GET}"
|
||||
PRESSURE_CARGO_PROFILE="${PRESSURE_CARGO_PROFILE:-release}"
|
||||
OUTPUT="${OUTPUT:-/tmp/aether_gateway_pressure_6k.json}"
|
||||
|
||||
args=(run)
|
||||
case "$PRESSURE_CARGO_PROFILE" in
|
||||
release)
|
||||
args+=(--release)
|
||||
;;
|
||||
debug)
|
||||
;;
|
||||
*)
|
||||
echo "unsupported PRESSURE_CARGO_PROFILE=$PRESSURE_CARGO_PROFILE; expected release or debug" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
args+=(
|
||||
-p aether-testkit --bin gateway_pressure_probe --
|
||||
--url "$TARGET_URL"
|
||||
--metrics-url "$METRICS_URL"
|
||||
--requests "$PRESSURE_REQUESTS"
|
||||
--concurrency "$PRESSURE_CONCURRENCY"
|
||||
--timeout-ms "$PRESSURE_TIMEOUT_MS"
|
||||
--sample-interval-ms "$PRESSURE_SAMPLE_INTERVAL_MS"
|
||||
--method "$PRESSURE_METHOD"
|
||||
--output "$OUTPUT"
|
||||
)
|
||||
|
||||
if [[ -n "${AUTH_HEADER:-}" ]]; then
|
||||
args+=(--header "$AUTH_HEADER")
|
||||
fi
|
||||
|
||||
if [[ -n "${EXTRA_HEADERS:-}" ]]; then
|
||||
while IFS= read -r header; do
|
||||
[[ -z "$header" ]] && continue
|
||||
args+=(--header "$header")
|
||||
done <<< "$EXTRA_HEADERS"
|
||||
fi
|
||||
|
||||
if [[ -n "${PRESSURE_BODY_FILE:-}" ]]; then
|
||||
args+=(--body-file "$PRESSURE_BODY_FILE")
|
||||
elif [[ -n "${PRESSURE_BODY:-}" ]]; then
|
||||
args+=(--body "$PRESSURE_BODY")
|
||||
fi
|
||||
|
||||
if [[ -n "${PRESSURE_RESPONSE_MODE:-}" ]]; then
|
||||
args+=(--response-mode "$PRESSURE_RESPONSE_MODE")
|
||||
fi
|
||||
|
||||
echo "running gateway pressure probe"
|
||||
echo " target: $TARGET_URL"
|
||||
echo " metrics: $METRICS_URL"
|
||||
echo " requests: $PRESSURE_REQUESTS"
|
||||
echo " concurrency: $PRESSURE_CONCURRENCY"
|
||||
echo " cargo: $PRESSURE_CARGO_PROFILE"
|
||||
echo " output: $OUTPUT"
|
||||
|
||||
# Use quiet cargo output so sensitive header values (for example Authorization)
|
||||
# are not echoed back as part of Cargo's `Running ...` command line.
|
||||
cargo -q "${args[@]}"
|
||||
|
||||
echo
|
||||
echo "pressure report written to $OUTPUT"
|
||||
Reference in New Issue
Block a user