perf: reduce gateway db pressure under load

This commit is contained in:
elky
2026-06-22 00:08:48 +08:00
parent 541cc197c4
commit f75894acbb
52 changed files with 4440 additions and 252 deletions
@@ -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
View File
@@ -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;
}
}
+5
View File
@@ -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::{
+7
View File
@@ -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,
}
}
}
+77 -8
View File
@@ -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 =
+23
View File
@@ -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()),
}
}
}
+144 -21
View File
@@ -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(),
}
}
}
+18 -6
View File
@@ -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>,
+33 -7
View File
@@ -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,
+13 -2
View File
@@ -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>,
+89 -4
View File
@@ -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(
+19 -2
View File
@@ -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);
@@ -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]"
);
}
+42 -7
View File
@@ -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 {
+5 -3
View File
@@ -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,
}
}
+7 -1
View File
@@ -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,
})
+277 -106
View File
@@ -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(
+216 -2
View File
@@ -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 {
+221
View File
@@ -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 与目标模型请求体。
+93
View File
@@ -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"