perf(gateway): scale request hot paths for 20k streams

Shard and singleflight hot-path caches, batch and prioritize candidate and usage lifecycle persistence, and extend database and pressure-test instrumentation for 20k concurrent streams.
This commit is contained in:
elky
2026-07-22 02:11:08 +08:00
parent 7756c0913f
commit fc92c4f431
124 changed files with 36325 additions and 3217 deletions
@@ -1161,36 +1161,54 @@ async fn resolve_priority_candidate_page_with_cache(
.resolved_page_cache_model_directive_policy_hash(),
cursor.resolution_mode,
);
let page_candidates_for_fallback = page_candidates.clone();
let page_candidates_for_load = page_candidates;
let cache = cursor.state.app().candidate_resolved_page_cache.clone();
let page_candidates_for_fallback = page_candidates;
let app = cursor.state.app();
let cache = app.candidate_resolved_page_cache.clone();
let ttl = candidate_page_cache_ttl_from_env();
let stale_ttl = candidate_page_cache_stale_ttl(ttl);
// Keep request-owned planner inputs borrowed until the cache tells us a
// cold load or stale refresh is actually needed. Fresh hits must not pay
// for deep copies of candidate pages and auth/routing snapshots.
let client_api_format = cursor.client_api_format.as_str();
let requested_model = cursor.requested_model.as_str();
let auth_snapshot = &cursor.auth_snapshot;
let client_session_affinity = cursor.client_session_affinity.as_ref();
let required_capabilities = cursor.required_capabilities.as_ref();
let routing_policy = cursor.routing_policy.as_ref();
let request_auth_channel = cursor.request_auth_channel.as_deref();
let resolution_mode = cursor.resolution_mode;
let cached = cache
.get_or_load_once_stale_while_refreshing(
.get_or_load_once_stale_while_revalidating(
key,
ttl,
stale_ttl,
|| async move {
let (candidates, resolved_skipped) =
resolve_and_rank_logical_local_execution_candidates(
cursor.state,
page_candidates_for_load,
&cursor.client_api_format,
Some(&cursor.requested_model),
Some(&cursor.auth_snapshot),
cursor.client_session_affinity.as_ref(),
cursor.required_capabilities.as_ref(),
cursor.routing_policy.as_ref(),
cursor.sticky_session_token.as_deref(),
cursor.request_auth_channel.as_deref(),
cursor.resolution_mode,
)
.await;
Ok::<_, GatewayError>(Some(Arc::new(CandidateResolvedPageSnapshot {
candidates,
resolved_skipped,
})))
|| {
resolve_candidate_page_snapshot(
(*app).clone(),
page_candidates_for_fallback.clone(),
client_api_format.to_owned(),
requested_model.to_owned(),
auth_snapshot.clone(),
client_session_affinity.cloned(),
required_capabilities.cloned(),
routing_policy.cloned(),
request_auth_channel.map(ToOwned::to_owned),
resolution_mode,
)
},
|| {
resolve_candidate_page_snapshot(
(*app).clone(),
page_candidates_for_fallback.clone(),
client_api_format.to_owned(),
requested_model.to_owned(),
auth_snapshot.clone(),
client_session_affinity.cloned(),
required_capabilities.cloned(),
routing_policy.cloned(),
request_auth_channel.map(ToOwned::to_owned),
resolution_mode,
)
},
CacheLoadObserver::new()
.on_hit(record_candidate_page_resolve_cache_hit)
@@ -1228,6 +1246,39 @@ async fn resolve_priority_candidate_page_with_cache(
}
}
async fn resolve_candidate_page_snapshot(
app: AppState,
page_candidates: Vec<SchedulerMinimalCandidateSelectionCandidate>,
client_api_format: String,
requested_model: String,
auth_snapshot: GatewayAuthApiKeySnapshot,
client_session_affinity: Option<ClientSessionAffinity>,
required_capabilities: Option<Value>,
routing_policy: Option<ResolvedRoutingPolicy>,
request_auth_channel: Option<String>,
resolution_mode: LocalCandidateResolutionMode,
) -> Result<Option<Arc<CandidateResolvedPageSnapshot>>, GatewayError> {
let state = PlannerAppState::new(&app);
let (candidates, resolved_skipped) = resolve_and_rank_logical_local_execution_candidates(
state,
page_candidates,
&client_api_format,
Some(&requested_model),
Some(&auth_snapshot),
client_session_affinity.as_ref(),
required_capabilities.as_ref(),
routing_policy.as_ref(),
None,
request_auth_channel.as_deref(),
resolution_mode,
)
.await;
Ok(Some(Arc::new(CandidateResolvedPageSnapshot {
candidates,
resolved_skipped,
})))
}
fn should_cache_resolved_candidate_page(cursor: &RequestedModelAttemptPageCursor<'_>) -> bool {
cursor.sticky_session_token.is_none()
&& cursor
@@ -321,6 +321,7 @@ pub(crate) struct LocalCandidatePreselectionPageCursor<'a> {
scanned_rows_by_format: BTreeMap<String, u32>,
resolved_global_model_names: BTreeMap<String, String>,
fallback_scanned_api_formats: BTreeSet<String>,
exhausted_api_formats: BTreeSet<String>,
seen_candidate_keys: BTreeSet<String>,
}
@@ -402,6 +403,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
scanned_rows_by_format: BTreeMap::new(),
resolved_global_model_names: BTreeMap::new(),
fallback_scanned_api_formats: BTreeSet::new(),
exhausted_api_formats: BTreeSet::new(),
seen_candidate_keys: BTreeSet::new(),
}
}
@@ -426,15 +428,38 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
}
}
// Deferred pages and formats already proven exhausted require no planning
// permit. This is the common second-target path for a single-candidate
// model, so keep it entirely in memory before joining the shared gate.
while self.format_index < self.candidate_api_formats.len() {
let candidate_api_format = self.candidate_api_formats[self.format_index].clone();
if let Some(outcome) = self.pop_deferred_page(&candidate_api_format) {
return Ok(Some(outcome));
}
let Some(outcome) = self
.next_page_for_api_format_with_planning_gate(&candidate_api_format)
.await?
else {
if self.api_format_is_exhausted(&candidate_api_format) {
self.format_index += 1;
continue;
}
break;
}
if self.format_index >= self.candidate_api_formats.len() {
return Ok(None);
}
// One next_page call may need to confirm exhaustion across several API
// formats. Hold one permit for that scan instead of rejoining the gate
// once per format.
let _permit = acquire_candidate_planning_gate(self.state, &self.trace_id).await?;
while self.format_index < self.candidate_api_formats.len() {
let candidate_api_format = self.candidate_api_formats[self.format_index].clone();
if let Some(outcome) = self.pop_deferred_page(&candidate_api_format) {
return Ok(Some(outcome));
}
if self.api_format_is_exhausted(&candidate_api_format) {
self.format_index += 1;
continue;
}
let Some(outcome) = self.next_page_for_api_format(&candidate_api_format).await? else {
self.format_index += 1;
continue;
};
@@ -453,6 +478,7 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
self.scanned_rows_by_format.clear();
self.resolved_global_model_names.clear();
self.fallback_scanned_api_formats.clear();
self.exhausted_api_formats.clear();
self.seen_candidate_keys.clear();
self.priority_page_emitted = false;
self.deferred_pages_by_format.clear();
@@ -656,22 +682,6 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
self.next_priority_page().await
}
async fn next_page_for_api_format_with_planning_gate(
&mut self,
candidate_api_format: &str,
) -> Result<
Option<
AiCandidatePreselectionOutcome<
SchedulerMinimalCandidateSelectionCandidate,
SkippedLocalExecutionCandidate,
>,
>,
GatewayError,
> {
let _permit = acquire_candidate_planning_gate(self.state, &self.trace_id).await?;
self.next_page_for_api_format(candidate_api_format).await
}
async fn split_priority_conversion_page(
&self,
candidate_api_format: &str,
@@ -802,6 +812,9 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
if normalized_api_format.is_empty() {
return Ok(None);
}
if self.exhausted_api_formats.contains(&normalized_api_format) {
return Ok(None);
}
let routing_model = self.routing_model(candidate_api_format).to_string();
let requested_names = requested_model_candidate_names(&routing_model, false);
let scanned = *self
@@ -809,6 +822,8 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.get(&normalized_api_format)
.unwrap_or(&0);
if scanned >= REQUESTED_MODEL_MAX_SCANNED_ROWS {
self.exhausted_api_formats
.insert(normalized_api_format.clone());
return Ok(None);
}
@@ -839,6 +854,8 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
.unwrap_or(&0);
let remaining = REQUESTED_MODEL_MAX_SCANNED_ROWS.saturating_sub(scanned);
if remaining == 0 {
self.exhausted_api_formats
.insert(normalized_api_format.clone());
return Ok(None);
}
let limit = REQUESTED_MODEL_CANDIDATE_PAGE_SIZE.min(remaining);
@@ -963,10 +980,12 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
>,
GatewayError,
> {
if !self
if self
.fallback_scanned_api_formats
.insert(normalized_api_format.to_string())
.contains(normalized_api_format)
{
self.exhausted_api_formats
.insert(normalized_api_format.to_string());
return Ok(None);
}
@@ -990,8 +1009,20 @@ impl<'a> LocalCandidatePreselectionPageCursor<'a> {
})
.collect::<Vec<_>>();
self.build_page_outcome_from_rows(candidate_api_format, normalized_api_format, rows)
.await
let outcome = self
.build_page_outcome_from_rows(candidate_api_format, normalized_api_format, rows)
.await?;
self.fallback_scanned_api_formats
.insert(normalized_api_format.to_string());
self.exhausted_api_formats
.insert(normalized_api_format.to_string());
Ok(outcome)
}
fn api_format_is_exhausted(&self, candidate_api_format: &str) -> bool {
let normalized_api_format = normalize_api_format(candidate_api_format);
normalized_api_format.is_empty()
|| self.exhausted_api_formats.contains(&normalized_api_format)
}
async fn build_page_outcome_from_rows(
@@ -1280,14 +1311,78 @@ mod tests {
use crate::AppState;
use aether_data::repository::candidate_selection::InMemoryMinimalCandidateSelectionReadRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::DataLayerError;
use aether_data_contracts::repository::candidate_selection::{
MinimalCandidateSelectionReadRepository, StoredProviderModelMapping,
MinimalCandidateSelectionReadRepository, StoredPoolKeyCandidateRowsByKeyIdsQuery,
StoredPoolKeyCandidateRowsQuery, StoredProviderModelMapping,
StoredRequestedModelCandidateRowsQuery,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use async_trait::async_trait;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
#[derive(Default)]
struct EmptyFallbackCountingRepository {
fallback_reads: AtomicUsize,
}
impl EmptyFallbackCountingRepository {
fn fallback_reads(&self) -> usize {
self.fallback_reads.load(Ordering::Acquire)
}
}
#[async_trait]
impl MinimalCandidateSelectionReadRepository for EmptyFallbackCountingRepository {
async fn list_for_exact_api_format(
&self,
_api_format: &str,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
self.fallback_reads.fetch_add(1, Ordering::AcqRel);
Ok(Vec::new())
}
async fn list_for_exact_api_format_and_global_model(
&self,
_api_format: &str,
_global_model_name: &str,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
Ok(Vec::new())
}
async fn list_for_exact_api_format_and_requested_model(
&self,
_api_format: &str,
_requested_model_name: &str,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
Ok(Vec::new())
}
async fn list_for_exact_api_format_and_requested_model_page(
&self,
_query: &StoredRequestedModelCandidateRowsQuery,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
Ok(Vec::new())
}
async fn list_pool_key_rows_for_group(
&self,
_query: &StoredPoolKeyCandidateRowsQuery,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
Ok(Vec::new())
}
async fn list_pool_key_rows_for_group_key_ids(
&self,
_query: &StoredPoolKeyCandidateRowsByKeyIdsQuery,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
Ok(Vec::new())
}
}
fn unrestricted_auth_snapshot() -> GatewayAuthApiKeySnapshot {
GatewayAuthApiKeySnapshot {
user_id: "user-1".to_string(),
@@ -1317,6 +1412,165 @@ mod tests {
}
}
#[tokio::test]
async fn empty_fallback_is_scanned_once_and_then_skipped_in_memory() {
let repository = Arc::new(EmptyFallbackCountingRepository::default());
let data_state =
GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository.clone());
let app = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = unrestricted_auth_snapshot();
let model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::load(&app).await;
let mut cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:search",
"missing-model",
None,
false,
None,
&auth_snapshot,
None,
None,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
false,
None,
)
.await;
assert!(cursor
.next_page()
.await
.expect("empty preselection should succeed")
.is_none());
assert_eq!(repository.fallback_reads(), 1);
assert!(cursor.api_format_is_exhausted("openai:search"));
// The second target must not reacquire the gate or rescan the empty
// fallback after the format was proven exhausted.
assert!(cursor
.next_page()
.await
.expect("exhausted preselection should succeed")
.is_none());
assert_eq!(repository.fallback_reads(), 1);
}
#[tokio::test]
async fn restart_scan_clears_exhaustion_and_allows_fallback_to_be_read_again() {
let repository = Arc::new(EmptyFallbackCountingRepository::default());
let data_state =
GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository.clone());
let app = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = unrestricted_auth_snapshot();
let model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::load(&app).await;
let mut cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:search",
"missing-model",
None,
false,
None,
&auth_snapshot,
None,
None,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
false,
None,
)
.await;
assert!(cursor
.next_page()
.await
.expect("initial scan should succeed")
.is_none());
assert_eq!(repository.fallback_reads(), 1);
cursor.restart_scan();
assert!(!cursor.api_format_is_exhausted("openai:search"));
assert!(cursor
.next_page()
.await
.expect("restarted scan should succeed")
.is_none());
assert_eq!(repository.fallback_reads(), 2);
}
#[tokio::test]
async fn fallback_can_supply_a_real_second_candidate_after_fast_path_page() {
let mut first = standard_candidate_row("provider-first", "openai:chat", 0);
first.global_model_name = "gpt-5".to_string();
first.global_model_mappings = Some(vec!["gpt-5(?:\\.\\d+)?".to_string()]);
first.model_provider_model_name = "gpt-5.1".to_string();
let mut second = standard_candidate_row("provider-second", "openai:chat", 1);
second.global_model_name = "gpt-5".to_string();
second.global_model_mappings = Some(vec!["gpt-5(?:\\.\\d+)?".to_string()]);
second.model_provider_model_name = "gpt-5-secondary".to_string();
let repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed([
first, second,
]));
let data_state =
GatewayDataState::with_minimal_candidate_selection_reader_for_tests(repository);
let app = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(data_state);
let auth_snapshot = unrestricted_auth_snapshot();
let model_directive_policy =
crate::system_features::ModelDirectivePolicySnapshot::load(&app).await;
let mut cursor = LocalCandidatePreselectionPageCursor::new(
PlannerAppState::new(&app),
&model_directive_policy,
"openai:chat",
"gpt-5.1",
None,
false,
None,
&auth_snapshot,
None,
None,
None,
true,
LocalCandidatePreselectionKeyMode::ProviderEndpointKeyModelAndApiFormat,
false,
None,
)
.await;
let first_page = cursor
.next_page()
.await
.expect("fast-path candidate should load")
.expect("first candidate should be present");
assert_eq!(first_page.candidates.len(), 1);
assert_eq!(first_page.candidates[0].provider_id, "provider-first");
let second_page = cursor
.next_page()
.await
.expect("fallback candidate should load")
.expect("second candidate should not be skipped");
assert_eq!(second_page.candidates.len(), 1);
assert_eq!(second_page.candidates[0].provider_id, "provider-second");
assert!(cursor
.next_page()
.await
.expect("exhausted formats should finish in memory")
.is_none());
}
#[tokio::test]
async fn priority_page_cache_requires_fixed_order_or_explicit_affinity() {
let repository: Arc<dyn MinimalCandidateSelectionReadRepository> =
@@ -18,6 +18,7 @@ use crate::ai_serving::{
ExecutionRuntimeAuthContext, GatewayAuthApiKeySnapshot, GatewayProviderTransportSnapshot,
PlannerAppState, CODEX_RESPONSES_LITE_HEADER,
};
use crate::cache::CacheLoadObserver;
use crate::client_session_affinity::client_session_affinity_from_api_request;
use crate::clock::current_unix_secs;
use crate::routing::{
@@ -29,7 +30,10 @@ use crate::routing::{
use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AiExecutionDecision, AppState, GatewayError};
// Keep normal freshness bounded for cross-node routing changes. Stale values
// are served while a single background refresh updates the cache.
const ROUTING_GROUP_SELECTION_CACHE_TTL: Duration = Duration::from_secs(30);
const ROUTING_GROUP_SELECTION_CACHE_STALE_TTL: Duration = Duration::from_secs(120);
const CODEX_ACCOUNT_ID_HEADER: &str = "chatgpt-account-id";
const CODEX_FEDRAMP_HEADER: &str = "x-openai-fedramp";
@@ -392,47 +396,64 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input(
let explicit_group = routing_header_value_str(&parts.headers, ROUTING_GROUP_HEADER);
let selected_group = match state.routing_group_read_repository() {
Some(repository) => {
let user_groups_lookup_started_at = std::time::Instant::now();
let user_group_ids = match state
.list_user_groups_for_user(&input.auth_context.user_id)
.await
{
Ok(groups) => groups.into_iter().map(|group| group.id).collect::<Vec<_>>(),
Err(error) => {
warn!(
user_id = %input.auth_context.user_id,
error = ?error,
"gateway routing profile user group lookup failed"
);
Vec::new()
}
// Explicit non-default groups are authorized against principal
// bindings, so both selection and its cache key must retain the
// caller context. Only the implicit no-binding system-default
// path is global and can skip the membership lookup.
let principal_context_required = if explicit_group.is_some() {
true
} else {
!matches!(repository.has_any_routing_group_binding().await, Ok(false))
};
observe_gateway_stage_ms(
"routing_user_groups_lookup",
user_groups_lookup_started_at.elapsed().as_millis() as u64,
);
let user_group_ids = if principal_context_required {
let user_groups_lookup_started_at = std::time::Instant::now();
let user_group_ids = match state
.list_user_groups_for_user(&input.auth_context.user_id)
.await
{
Ok(groups) => groups.into_iter().map(|group| group.id).collect::<Vec<_>>(),
Err(error) => {
warn!(
user_id = %input.auth_context.user_id,
error = ?error,
"gateway routing profile user group lookup failed"
);
Vec::new()
}
};
observe_gateway_stage_ms(
"routing_user_groups_lookup",
user_groups_lookup_started_at.elapsed().as_millis() as u64,
);
user_group_ids
} else {
Vec::new()
};
let selection_user_id =
principal_context_required.then(|| input.auth_context.user_id.clone());
let selection_api_key_id =
principal_context_required.then(|| input.auth_context.api_key_id.clone());
let selection_cache_key = routing_group_selection_cache_key(
explicit_group.as_deref(),
Some(input.auth_context.user_id.as_str()),
Some(input.auth_context.api_key_id.as_str()),
selection_user_id.as_deref(),
selection_api_key_id.as_deref(),
&user_group_ids,
);
let user_id = input.auth_context.user_id.clone();
let api_key_id = input.auth_context.api_key_id.clone();
let group_selection_started_at = std::time::Instant::now();
let selection = state
.routing_group_selection_cache
.get_or_load_once(
.get_or_load_once_stale_while_revalidating(
selection_cache_key,
ROUTING_GROUP_SELECTION_CACHE_TTL,
|| async move {
ROUTING_GROUP_SELECTION_CACHE_STALE_TTL,
|| async {
let selection_load_started_at = std::time::Instant::now();
let selection = select_gateway_routing_group(
repository.as_ref(),
GatewayRoutingSelectionInput {
explicit_group: explicit_group.as_deref(),
user_id: Some(user_id.as_str()),
api_key_id: Some(api_key_id.as_str()),
user_id: selection_user_id.as_deref(),
api_key_id: selection_api_key_id.as_deref(),
user_group_ids: &user_group_ids,
},
)
@@ -444,6 +465,33 @@ pub(crate) async fn attach_routing_policy_to_local_requested_model_input(
);
Ok::<_, GatewayError>(Some(selection))
},
|| {
let repository = repository.clone();
let explicit_group = explicit_group.clone();
let user_id = selection_user_id.clone();
let api_key_id = selection_api_key_id.clone();
let user_group_ids = user_group_ids.clone();
async move {
let selection_load_started_at = std::time::Instant::now();
let selection = select_gateway_routing_group(
repository.as_ref(),
GatewayRoutingSelectionInput {
explicit_group: explicit_group.as_deref(),
user_id: user_id.as_deref(),
api_key_id: api_key_id.as_deref(),
user_group_ids: &user_group_ids,
},
)
.await
.map_err(routing_selection_error)?;
observe_gateway_stage_ms(
"routing_group_selection_load",
selection_load_started_at.elapsed().as_millis() as u64,
);
Ok::<_, GatewayError>(Some(selection))
}
},
CacheLoadObserver::default(),
)
.await?
.unwrap_or_default();
@@ -919,12 +967,119 @@ fn ensure_report_context_routing_trace(
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::*;
use aether_data::repository::routing_profiles::InMemoryRoutingGroupRepository;
use aether_data_contracts::repository::routing_profiles::{
CreateRoutingGroupBindingRecord, CreateRoutingGroupRecord, RoutingGroupBindingSubject,
RoutingGroupWriteRepository,
};
use aether_provider_transport::snapshot::{
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
GatewayProviderTransportProvider,
};
#[test]
fn explicit_routing_selection_cache_key_is_principal_specific() {
let first = routing_group_selection_cache_key(
Some("private"),
Some("user-1"),
Some("key-1"),
&["team-1".to_string()],
);
let second = routing_group_selection_cache_key(
Some("private"),
Some("user-2"),
Some("key-2"),
&["team-2".to_string()],
);
assert_ne!(first, second);
assert!(first.contains("user=user-1"));
assert!(first.contains("api_key=key-1"));
assert!(first.contains("groups=team-1"));
}
#[tokio::test]
async fn explicit_routing_attachment_authorizes_and_caches_per_principal() {
let repository = Arc::new(InMemoryRoutingGroupRepository::default());
repository
.create_routing_group(CreateRoutingGroupRecord {
id: "private-group".to_string(),
name: "private".to_string(),
description: None,
enabled: true,
is_system_default: false,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.unwrap();
repository
.create_routing_group_binding(CreateRoutingGroupBindingRecord {
id: "binding-user-1".to_string(),
group_id: "private-group".to_string(),
subject_type: RoutingGroupBindingSubject::User,
subject_id: "user-1".to_string(),
is_default: false,
allow_explicit_select: true,
created_at: 1,
updated_at: 1,
})
.await
.unwrap();
let state = AppState::new().unwrap().with_data_state_for_tests(
crate::data::GatewayDataState::disabled()
.with_routing_group_repository_for_tests(repository),
);
let (parts, _) = http::Request::builder()
.header(ROUTING_GROUP_HEADER, "private-group")
.body(())
.unwrap()
.into_parts();
let mut allowed = sample_decision_input();
attach_routing_policy_to_local_requested_model_input(
&state,
&parts,
&mut allowed,
&json!({"model": "gpt-5"}),
"openai:chat",
)
.await
.expect("bound principal should explicitly select the private group");
let policy = allowed
.routing_policy
.as_ref()
.expect("explicit selection should attach routing policy");
assert_eq!(policy.group_id.as_deref(), Some("private-group"));
assert_eq!(policy.selection_source, "explicit_header");
let mut denied = sample_decision_input();
denied.auth_context.user_id = "user-2".to_string();
denied.auth_context.api_key_id = "api-key-2".to_string();
let error = attach_routing_policy_to_local_requested_model_input(
&state,
&parts,
&mut denied,
&json!({"model": "gpt-5"}),
"openai:chat",
)
.await
.expect_err("another principal must not reuse the authorized cache entry");
match error {
GatewayError::Client { status, message } => {
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(message.contains("not allowed for this principal"));
}
other => panic!("unexpected explicit selection error: {other:?}"),
}
}
fn sample_auth_context() -> ExecutionRuntimeAuthContext {
ExecutionRuntimeAuthContext {
user_id: "user-1".to_string(),
+625 -27
View File
@@ -1,37 +1,158 @@
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use aether_cache::ExpiringMap;
use tokio::sync::futures::OwnedNotified;
use tokio::sync::Notify;
use crate::control::GatewayControlAuthContext;
use crate::{control::GatewayControlAuthContext, GatewayError};
#[derive(Debug)]
pub(crate) struct AuthContextCache {
entries: ExpiringMap<String, GatewayControlAuthContext>,
inflight: std::sync::Mutex<HashSet<String>>,
notify: Notify,
inflight: std::sync::Mutex<HashMap<String, Arc<AuthContextInflightState>>>,
// Guards cache publication across invalidation. A request that started
// before a mutation must not repopulate the cache after it is cleared.
generation: AtomicU64,
mutation: std::sync::Mutex<()>,
#[cfg(test)]
refresh_interval_override_millis: AtomicU64,
}
#[derive(Clone, Debug)]
pub(crate) struct AuthContextCacheGeneration {
global: u64,
state: Arc<AuthContextInflightState>,
}
#[derive(Debug)]
struct AuthContextInflightState {
completed: AtomicBool,
publishable: AtomicBool,
error: std::sync::Mutex<Option<GatewayError>>,
notify: Arc<Notify>,
}
impl AuthContextInflightState {
fn new() -> Self {
Self {
completed: AtomicBool::new(false),
publishable: AtomicBool::new(true),
error: std::sync::Mutex::new(None),
notify: Arc::new(Notify::new()),
}
}
fn waiter(self: &Arc<Self>) -> AuthContextInflightWaiter {
AuthContextInflightWaiter {
state: Arc::clone(self),
notified: Arc::clone(&self.notify).notified_owned(),
}
}
fn complete(&self) {
self.completed.store(true, Ordering::Release);
self.notify.notify_waiters();
}
fn invalidate(&self) {
self.publishable.store(false, Ordering::Release);
self.complete();
}
fn fail(&self, error: GatewayError) {
self.publishable.store(false, Ordering::Release);
if let Ok(mut current) = self.error.lock() {
*current = Some(error);
}
self.complete();
}
fn error(&self) -> Option<GatewayError> {
self.error.lock().ok().and_then(|error| error.clone())
}
}
pub(crate) struct AuthContextInflightWaiter {
state: Arc<AuthContextInflightState>,
notified: OwnedNotified,
}
impl AuthContextInflightWaiter {
pub(crate) async fn wait(self) -> Result<(), GatewayError> {
let Self { state, notified } = self;
if state.completed.load(Ordering::Acquire) {
return state.error().map_or(Ok(()), Err);
}
tokio::pin!(notified);
if notified.as_mut().enable() || state.completed.load(Ordering::Acquire) {
return state.error().map_or(Ok(()), Err);
}
notified.await;
state.error().map_or(Ok(()), Err)
}
}
impl Default for AuthContextCache {
fn default() -> Self {
Self {
entries: ExpiringMap::default(),
inflight: std::sync::Mutex::new(HashSet::new()),
notify: Notify::new(),
inflight: std::sync::Mutex::new(HashMap::new()),
generation: AtomicU64::new(0),
mutation: std::sync::Mutex::new(()),
#[cfg(test)]
refresh_interval_override_millis: AtomicU64::new(0),
}
}
}
pub(crate) enum AuthContextInflightRegistration<'a> {
Leader(AuthContextInflightGuard<'a>),
Follower,
Follower(AuthContextInflightWaiter),
Bypass,
}
pub(crate) struct AuthContextInflightGuard<'a> {
cache: &'a AuthContextCache,
cache_key: Option<String>,
state: Arc<AuthContextInflightState>,
generation: AuthContextCacheGeneration,
}
pub(crate) struct AuthContextOwnedInflightGuard {
cache: Arc<AuthContextCache>,
cache_key: Option<String>,
state: Arc<AuthContextInflightState>,
generation: AuthContextCacheGeneration,
}
impl AuthContextInflightGuard<'_> {
pub(crate) fn generation(&self) -> AuthContextCacheGeneration {
self.generation.clone()
}
pub(crate) fn generation_is_current(&self) -> bool {
self.cache.generation_is_current(&self.generation)
}
pub(crate) fn fail(&self, error: GatewayError) {
if let Some(cache_key) = self.cache_key.as_deref() {
self.cache.fail_inflight(cache_key, &self.state, error);
}
}
}
impl AuthContextOwnedInflightGuard {
pub(crate) fn generation(&self) -> AuthContextCacheGeneration {
self.generation.clone()
}
pub(crate) fn generation_is_current(&self) -> bool {
self.cache.generation_is_current(&self.generation)
}
}
impl Drop for AuthContextInflightGuard<'_> {
@@ -39,19 +160,81 @@ impl Drop for AuthContextInflightGuard<'_> {
let Some(cache_key) = self.cache_key.take() else {
return;
};
let removed = self
.cache
.inflight
.lock()
.map(|mut inflight| inflight.remove(&cache_key))
.unwrap_or(false);
if removed {
self.cache.notify.notify_waiters();
}
self.cache.finish_inflight(&cache_key, &self.state);
}
}
impl Drop for AuthContextOwnedInflightGuard {
fn drop(&mut self) {
let Some(cache_key) = self.cache_key.take() else {
return;
};
self.cache.finish_inflight(&cache_key, &self.state);
}
}
impl AuthContextCache {
fn finish_inflight(&self, cache_key: &str, state: &Arc<AuthContextInflightState>) {
let removed = self
.inflight
.lock()
.map(|mut inflight| {
if inflight
.get(cache_key)
.is_some_and(|current| Arc::ptr_eq(current, state))
{
state.complete();
inflight.remove(cache_key).is_some()
} else {
false
}
})
.unwrap_or(false);
if !removed {
// A clear may already have detached and completed this flight.
// Keep drop idempotent so an old guard cannot affect its replacement.
return;
}
}
fn fail_inflight(
&self,
cache_key: &str,
state: &Arc<AuthContextInflightState>,
error: GatewayError,
) {
let Ok(_mutation) = self.mutation.lock() else {
state.fail(error);
return;
};
let removed_current = match self.inflight.lock() {
Ok(mut inflight) => {
if inflight
.get(cache_key)
.is_some_and(|current| Arc::ptr_eq(current, state))
{
inflight.remove(cache_key);
true
} else {
false
}
}
Err(_) => {
state.fail(error);
return;
}
};
if removed_current {
self.remove_entries_for_key(cache_key);
state.fail(error);
}
}
fn remove_entries_for_key(&self, cache_key: &str) {
self.entries.remove(&cache_key.to_string());
self.entries.remove(&format!("negative:{cache_key}"));
}
pub(crate) fn get_fresh(
&self,
cache_key: &str,
@@ -60,6 +243,14 @@ impl AuthContextCache {
self.entries.get_fresh(&cache_key.to_string(), ttl)
}
pub(crate) fn get_fresh_with_age(
&self,
cache_key: &str,
ttl: Duration,
) -> Option<(GatewayControlAuthContext, Duration)> {
self.entries.get_with_age(&cache_key.to_string(), ttl)
}
pub(crate) fn insert(
&self,
cache_key: String,
@@ -67,12 +258,60 @@ impl AuthContextCache {
ttl: Duration,
max_entries: usize,
) {
let Ok(_mutation) = self.mutation.lock() else {
return;
};
self.entries
.insert(cache_key, auth_context, ttl, max_entries);
}
pub(crate) fn notified(&self) -> tokio::sync::futures::Notified<'_> {
self.notify.notified()
pub(crate) fn insert_if_generation(
&self,
cache_key: String,
auth_context: GatewayControlAuthContext,
ttl: Duration,
max_entries: usize,
generation: &AuthContextCacheGeneration,
) -> bool {
let Ok(_mutation) = self.mutation.lock() else {
return false;
};
if !self.generation_is_current(generation) {
return false;
}
self.entries
.insert(cache_key, auth_context, ttl, max_entries);
true
}
fn generation_for_state(
&self,
state: &Arc<AuthContextInflightState>,
) -> AuthContextCacheGeneration {
AuthContextCacheGeneration {
global: self.generation.load(Ordering::Acquire),
state: Arc::clone(state),
}
}
fn generation_is_current(&self, generation: &AuthContextCacheGeneration) -> bool {
self.generation.load(Ordering::Acquire) == generation.global
&& generation.state.publishable.load(Ordering::Acquire)
}
#[cfg(test)]
pub(crate) fn set_refresh_interval_for_tests(&self, interval: Duration) {
let millis = u64::try_from(interval.as_millis()).unwrap_or(u64::MAX);
self.refresh_interval_override_millis
.store(millis.max(1), Ordering::Release);
}
#[cfg(test)]
pub(crate) fn refresh_interval_for_tests(&self) -> Option<Duration> {
let millis = self
.refresh_interval_override_millis
.load(Ordering::Acquire);
(millis > 0).then(|| Duration::from_millis(millis))
}
pub(crate) fn register_inflight(&self, cache_key: &str) -> AuthContextInflightRegistration<'_> {
@@ -80,15 +319,24 @@ impl AuthContextCache {
if cache_key.is_empty() {
return AuthContextInflightRegistration::Bypass;
}
let Ok(_mutation) = self.mutation.lock() else {
return AuthContextInflightRegistration::Bypass;
};
match self.inflight.lock() {
Ok(mut inflight) => {
if inflight.contains(cache_key) {
AuthContextInflightRegistration::Follower
if let Some(state) = inflight.get(cache_key) {
// Create the waiter while holding the map lock so leader
// completion cannot race between lookup and registration.
AuthContextInflightRegistration::Follower(state.waiter())
} else {
inflight.insert(cache_key.to_string());
let state = Arc::new(AuthContextInflightState::new());
let generation = self.generation_for_state(&state);
inflight.insert(cache_key.to_string(), Arc::clone(&state));
AuthContextInflightRegistration::Leader(AuthContextInflightGuard {
cache: self,
cache_key: Some(cache_key.to_string()),
state,
generation,
})
}
}
@@ -96,14 +344,364 @@ impl AuthContextCache {
}
}
pub(crate) fn try_register_owned_leader(
self: &Arc<Self>,
cache_key: &str,
) -> Option<AuthContextOwnedInflightGuard> {
let cache_key = cache_key.trim();
if cache_key.is_empty() {
return None;
}
let Ok(_mutation) = self.mutation.lock() else {
return None;
};
let Ok(mut inflight) = self.inflight.lock() else {
return None;
};
if inflight.contains_key(cache_key) {
return None;
}
let state = Arc::new(AuthContextInflightState::new());
let generation = self.generation_for_state(&state);
inflight.insert(cache_key.to_string(), Arc::clone(&state));
Some(AuthContextOwnedInflightGuard {
cache: Arc::clone(self),
cache_key: Some(cache_key.to_string()),
state,
generation,
})
}
pub(crate) fn clear(&self) {
let Ok(_mutation) = self.mutation.lock() else {
return;
};
self.generation.fetch_add(1, Ordering::AcqRel);
self.entries.clear();
if let Ok(mut inflight) = self.inflight.lock() {
let had_inflight = !inflight.is_empty();
inflight.clear();
if had_inflight {
self.notify.notify_waiters();
}
let states = self
.inflight
.lock()
.map(|mut inflight| inflight.drain().map(|(_, state)| state).collect::<Vec<_>>())
.unwrap_or_default();
for state in states {
state.invalidate();
}
}
pub(crate) fn invalidate(&self, cache_key: &str) {
let cache_key = cache_key.trim();
if cache_key.is_empty() {
return;
}
let Ok(_mutation) = self.mutation.lock() else {
return;
};
self.remove_entries_for_key(cache_key);
let state = self
.inflight
.lock()
.ok()
.and_then(|mut inflight| inflight.remove(cache_key));
if let Some(state) = state {
state.invalidate();
}
}
}
#[cfg(test)]
mod tests {
use super::{AuthContextCache, AuthContextInflightRegistration};
use crate::{control::GatewayControlAuthContext, GatewayError};
use std::sync::Arc;
use std::time::Duration;
fn context(api_key_id: &str) -> GatewayControlAuthContext {
GatewayControlAuthContext {
user_id: "user-1".to_string(),
api_key_id: api_key_id.to_string(),
username: None,
api_key_name: None,
balance_remaining: None,
access_allowed: true,
user_rate_limit: None,
api_key_rate_limit: None,
api_key_is_standalone: false,
admin_bypass_limits: false,
local_rejection: None,
allowed_models: None,
ip_rules: None,
}
}
#[tokio::test]
async fn auth_context_singleflight_notifies_only_matching_key() {
let cache = AuthContextCache::default();
let leader_a = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("key-a should register a leader"),
};
let leader_b = match cache.register_inflight("key-b") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("key-b should register a leader"),
};
let follower_a = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Follower(notified) => notified,
_ => panic!("second key-a registration should follow"),
};
let follower_b = match cache.register_inflight("key-b") {
AuthContextInflightRegistration::Follower(notified) => notified,
_ => panic!("second key-b registration should follow"),
};
drop(leader_b);
tokio::time::timeout(Duration::from_millis(100), follower_b.wait())
.await
.expect("key-b follower should wake when key-b completes")
.expect("successful flight should not publish an error");
assert!(
tokio::time::timeout(Duration::from_millis(20), follower_a.wait())
.await
.is_err(),
"key-a follower must not wake when unrelated key-b completes"
);
drop(leader_a);
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[test]
fn clear_rejects_publication_from_old_inflight_generation() {
let cache = AuthContextCache::default();
let leader = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("key-a should register a leader"),
};
let old_generation = leader.generation();
cache.clear();
assert!(!leader.generation_is_current());
assert!(!cache.insert_if_generation(
"key-a".to_string(),
context("stale-key"),
Duration::from_secs(60),
10,
&old_generation,
));
assert!(cache.get_fresh("key-a", Duration::from_secs(60)).is_none());
}
#[tokio::test]
async fn invalidate_removes_only_one_key_and_releases_its_followers() {
let cache = AuthContextCache::default();
cache.insert(
"key-a".to_string(),
context("api-key-a"),
Duration::from_secs(60),
10,
);
cache.insert(
"key-b".to_string(),
context("api-key-b"),
Duration::from_secs(60),
10,
);
cache.insert(
"negative:key-a".to_string(),
context("negative-api-key-a"),
Duration::from_secs(60),
10,
);
let old_leader = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("key-a should register a leader"),
};
let old_generation = old_leader.generation();
let follower = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second key-a registration should follow"),
};
cache.invalidate("key-a");
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("invalidating key-a should release its follower")
.expect("explicit invalidation should allow a retry");
assert!(cache.get_fresh("key-a", Duration::from_secs(60)).is_none());
assert!(cache
.get_fresh("negative:key-a", Duration::from_secs(60))
.is_none());
assert_eq!(
cache
.get_fresh("key-b", Duration::from_secs(60))
.expect("unrelated cache entry should survive")
.api_key_id,
"api-key-b"
);
assert!(!old_leader.generation_is_current());
assert!(!cache.insert_if_generation(
"key-a".to_string(),
context("stale-key"),
Duration::from_secs(60),
10,
&old_generation,
));
let replacement = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("invalidated key should admit a replacement leader"),
};
drop(old_leader);
assert!(matches!(
cache.register_inflight("key-a"),
AuthContextInflightRegistration::Follower(_)
));
drop(replacement);
}
#[tokio::test]
async fn leader_drop_before_follower_first_poll_does_not_lose_wakeup() {
let cache = AuthContextCache::default();
let leader = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
drop(leader);
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("completion before first poll must release the follower")
.expect("successful flight should not publish an error");
}
#[tokio::test]
async fn flight_failure_is_shared_and_cannot_remove_replacement() {
let cache = AuthContextCache::default();
cache.insert(
"key-a".to_string(),
context("stale-key"),
Duration::from_secs(60),
10,
);
let old_leader = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
old_leader.fail(GatewayError::Internal(
"forced auth load failure".to_string(),
));
let error = tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("failed flight should release its follower")
.expect_err("follower should observe the leader error");
assert_eq!(error.into_message(), "forced auth load failure");
assert!(cache.get_fresh("key-a", Duration::from_secs(60)).is_none());
let replacement = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("failed flight should allow a replacement"),
};
drop(old_leader);
assert!(matches!(
cache.register_inflight("key-a"),
AuthContextInflightRegistration::Follower(_)
));
drop(replacement);
}
#[tokio::test]
async fn invalidation_wins_over_detached_leader_failure() {
let cache = AuthContextCache::default();
let old_leader = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
cache.invalidate("key-a");
old_leader.fail(GatewayError::Internal(
"superseded auth load failure".to_string(),
));
follower
.wait()
.await
.expect("invalidation should keep the detached flight retryable");
}
#[test]
fn owned_refresh_allows_only_one_leader_per_key() {
let cache = Arc::new(AuthContextCache::default());
let leader = cache
.try_register_owned_leader("key-a")
.expect("first refresh should lead");
assert!(cache.try_register_owned_leader("key-a").is_none());
assert!(cache.try_register_owned_leader("key-b").is_some());
drop(leader);
assert!(cache.try_register_owned_leader("key-a").is_some());
}
#[test]
fn clear_rejects_owned_refresh_publication_and_preserves_replacement() {
let cache = Arc::new(AuthContextCache::default());
let old_leader = cache
.try_register_owned_leader("key-a")
.expect("first refresh should lead");
let old_generation = old_leader.generation();
cache.clear();
let replacement = cache
.try_register_owned_leader("key-a")
.expect("clear should allow a replacement refresh");
assert!(!old_leader.generation_is_current());
assert!(!cache.insert_if_generation(
"key-a".to_string(),
context("stale-key"),
Duration::from_secs(60),
10,
&old_generation,
));
drop(old_leader);
assert!(cache.try_register_owned_leader("key-a").is_none());
drop(replacement);
assert!(cache.try_register_owned_leader("key-a").is_some());
}
#[tokio::test]
async fn cancelled_owned_refresh_wakes_waiters_and_allows_retry() {
let cache = Arc::new(AuthContextCache::default());
let guard = cache
.try_register_owned_leader("key-a")
.expect("first refresh should lead");
let task = tokio::spawn(async move {
let _guard = guard;
std::future::pending::<()>().await;
});
let follower = match cache.register_inflight("key-a") {
AuthContextInflightRegistration::Follower(waiter) => waiter,
_ => panic!("hard miss should follow the background refresh"),
};
task.abort();
let _ = task.await;
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("cancellation must release existing waiters")
.expect("cancellation should allow a retry");
assert!(cache.try_register_owned_leader("key-a").is_some());
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -25,7 +25,7 @@ const MAX_CANDIDATE_PAGE_CACHE_TTL_MS: u64 = 1_000;
const CANDIDATE_PAGE_CACHE_TTL_ENV: &str = "AETHER_GATEWAY_CANDIDATE_PAGE_CACHE_TTL_MS";
const DEFAULT_CANDIDATE_PAGE_CACHE_STALE_TTL_MS: u64 = 300_000;
const MIN_CANDIDATE_PAGE_CACHE_STALE_TTL_MS: u64 = 1_000;
const MAX_CANDIDATE_PAGE_CACHE_STALE_TTL_MS: u64 = 300_000;
const MAX_CANDIDATE_PAGE_CACHE_STALE_TTL_MS: u64 = 3_600_000;
const CANDIDATE_PAGE_CACHE_STALE_TTL_ENV: &str = "AETHER_GATEWAY_CANDIDATE_PAGE_CACHE_STALE_TTL_MS";
pub(crate) type CandidatePageSnapshot = AiCandidatePreselectionOutcome<
+3 -1
View File
@@ -8,7 +8,9 @@ mod scheduler_affinity;
mod system_config;
pub(crate) use auth_api_key_last_used::AuthApiKeyLastUsedCache;
pub(crate) use auth_context::{AuthContextCache, AuthContextInflightRegistration};
pub(crate) use auth_context::{
AuthContextCache, AuthContextCacheGeneration, AuthContextInflightRegistration,
};
pub(crate) use auth_runtime::{
AuthApiKeyFeatureCacheKey, AuthApiKeyIdentityCacheKey, AuthSnapshotCache, AuthSnapshotCacheKey,
CacheLoadObserver, JsonValueCache, ValueCache,
+447 -35
View File
@@ -1,66 +1,216 @@
use std::collections::HashSet;
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use aether_cache::ExpiringMap;
use tokio::sync::futures::OwnedNotified;
use tokio::sync::Notify;
use crate::GatewayError;
const MAX_ENTRIES: usize = 512;
#[derive(Debug)]
pub(crate) struct SystemConfigCache {
entries: ExpiringMap<String, Option<serde_json::Value>>,
inflight: std::sync::Mutex<HashSet<String>>,
notify: Notify,
inflight: std::sync::Mutex<HashMap<String, Arc<SystemConfigInflightState>>>,
generation: AtomicU64,
mutation: std::sync::Mutex<()>,
}
#[derive(Debug)]
struct SystemConfigInflightState {
completed: AtomicBool,
error: std::sync::Mutex<Option<GatewayError>>,
notify: Arc<Notify>,
}
impl SystemConfigInflightState {
fn new() -> Self {
Self {
completed: AtomicBool::new(false),
error: std::sync::Mutex::new(None),
notify: Arc::new(Notify::new()),
}
}
fn waiter(self: &Arc<Self>) -> SystemConfigInflightWaiter {
SystemConfigInflightWaiter {
state: Arc::clone(self),
notified: Arc::clone(&self.notify).notified_owned(),
}
}
fn complete(&self) {
self.completed.store(true, Ordering::Release);
self.notify.notify_waiters();
}
fn fail(&self, error: GatewayError) {
if let Ok(mut current) = self.error.lock() {
*current = Some(error);
}
self.complete();
}
fn error(&self) -> Option<GatewayError> {
self.error.lock().ok().and_then(|error| error.clone())
}
}
impl Default for SystemConfigCache {
fn default() -> Self {
Self {
entries: ExpiringMap::new(),
inflight: std::sync::Mutex::new(HashSet::new()),
notify: Notify::new(),
inflight: std::sync::Mutex::new(HashMap::new()),
generation: AtomicU64::new(0),
mutation: std::sync::Mutex::new(()),
}
}
}
pub(crate) enum SystemConfigInflightRegistration<'a> {
Leader(SystemConfigInflightGuard<'a>),
Follower,
Follower(SystemConfigInflightWaiter),
Bypass,
}
pub(crate) struct SystemConfigInflightWaiter {
state: Arc<SystemConfigInflightState>,
notified: OwnedNotified,
}
impl SystemConfigInflightWaiter {
pub(crate) async fn wait(self) -> Result<(), GatewayError> {
let Self { state, notified } = self;
if state.completed.load(Ordering::Acquire) {
return state.error().map_or(Ok(()), Err);
}
tokio::pin!(notified);
if notified.as_mut().enable() || state.completed.load(Ordering::Acquire) {
return state.error().map_or(Ok(()), Err);
}
notified.await;
state.error().map_or(Ok(()), Err)
}
}
pub(crate) struct SystemConfigInflightGuard<'a> {
cache: &'a SystemConfigCache,
key: Option<String>,
state: Arc<SystemConfigInflightState>,
generation: u64,
}
impl Drop for SystemConfigInflightGuard<'_> {
fn drop(&mut self) {
if let Some(key) = self.key.take() {
self.cache.finish_load(&key);
pub(crate) struct SystemConfigOwnedInflightGuard {
cache: Arc<SystemConfigCache>,
key: Option<String>,
state: Arc<SystemConfigInflightState>,
generation: u64,
}
impl SystemConfigInflightGuard<'_> {
pub(crate) fn generation(&self) -> u64 {
self.generation
}
pub(crate) fn fail(&self, error: GatewayError) {
if let Some(key) = self.key.as_deref() {
self.cache.fail_load(key, &self.state, error);
}
}
}
impl SystemConfigCache {
pub(crate) fn get(&self, key: &str, ttl: Duration) -> Option<Option<serde_json::Value>> {
self.entries.get_fresh(&key.to_string(), ttl)
impl SystemConfigOwnedInflightGuard {
pub(crate) fn generation(&self) -> u64 {
self.generation
}
pub(crate) fn insert(&self, key: String, value: Option<serde_json::Value>, ttl: Duration) {
self.entries.insert(key, value, ttl, MAX_ENTRIES);
pub(crate) fn fail(&self, error: GatewayError) {
if let Some(key) = self.key.as_deref() {
self.cache.fail_load(key, &self.state, error);
}
}
}
impl Drop for SystemConfigInflightGuard<'_> {
fn drop(&mut self) {
let Some(key) = self.key.take() else {
return;
};
self.cache.finish_load(&key, &self.state);
}
}
impl Drop for SystemConfigOwnedInflightGuard {
fn drop(&mut self) {
let Some(key) = self.key.take() else {
return;
};
self.cache.finish_load(&key, &self.state);
}
}
impl SystemConfigCache {
pub(crate) fn get_with_age(
&self,
key: &str,
max_age: Duration,
) -> Option<(Option<serde_json::Value>, Duration)> {
self.entries.get_with_age(&key.to_string(), max_age)
}
/// Publishes a value written through the application and invalidates any
/// older loads before they can overwrite it.
pub(crate) fn insert(&self, key: String, value: Option<serde_json::Value>, max_age: Duration) {
let Ok(_mutation) = self.mutation.lock() else {
return;
};
self.generation.fetch_add(1, Ordering::AcqRel);
self.entries.insert(key, value, max_age, MAX_ENTRIES);
self.detach_all_loads();
}
pub(crate) fn insert_if_generation(
&self,
key: String,
value: Option<serde_json::Value>,
max_age: Duration,
generation: u64,
) -> bool {
let Ok(_mutation) = self.mutation.lock() else {
return false;
};
if self.generation.load(Ordering::Acquire) != generation {
return false;
}
self.entries.insert(key, value, max_age, MAX_ENTRIES);
true
}
pub(crate) fn register_load(&self, key: &str) -> SystemConfigInflightRegistration<'_> {
let key = key.trim();
if key.is_empty() {
return SystemConfigInflightRegistration::Bypass;
}
let Ok(_mutation) = self.mutation.lock() else {
return SystemConfigInflightRegistration::Bypass;
};
let generation = self.generation.load(Ordering::Acquire);
match self.inflight.lock() {
Ok(mut inflight) => {
if inflight.contains(key) {
SystemConfigInflightRegistration::Follower
if let Some(state) = inflight.get(key) {
SystemConfigInflightRegistration::Follower(state.waiter())
} else {
inflight.insert(key.to_string());
let state = Arc::new(SystemConfigInflightState::new());
inflight.insert(key.to_string(), Arc::clone(&state));
SystemConfigInflightRegistration::Leader(SystemConfigInflightGuard {
cache: self,
key: Some(key.to_string()),
state,
generation,
})
}
}
@@ -68,34 +218,296 @@ impl SystemConfigCache {
}
}
pub(crate) fn notified(&self) -> tokio::sync::futures::Notified<'_> {
self.notify.notified()
pub(crate) fn try_register_owned_leader(
self: &Arc<Self>,
key: &str,
) -> Option<SystemConfigOwnedInflightGuard> {
let key = key.trim();
if key.is_empty() {
return None;
}
let Ok(_mutation) = self.mutation.lock() else {
return None;
};
let generation = self.generation.load(Ordering::Acquire);
let Ok(mut inflight) = self.inflight.lock() else {
return None;
};
if inflight.contains_key(key) {
return None;
}
let state = Arc::new(SystemConfigInflightState::new());
inflight.insert(key.to_string(), Arc::clone(&state));
Some(SystemConfigOwnedInflightGuard {
cache: Arc::clone(self),
key: Some(key.to_string()),
state,
generation,
})
}
fn finish_load(&self, key: &str) {
fn finish_load(&self, key: &str, state: &Arc<SystemConfigInflightState>) {
let removed = self
.inflight
.lock()
.map(|mut inflight| inflight.remove(key))
.map(|mut inflight| {
inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
&& inflight.remove(key).is_some()
})
.unwrap_or(false);
if removed {
self.notify.notify_waiters();
state.complete();
}
}
fn fail_load(&self, key: &str, state: &Arc<SystemConfigInflightState>, error: GatewayError) {
let Ok(_mutation) = self.mutation.lock() else {
state.fail(error);
return;
};
let removed_current = self
.inflight
.lock()
.map(|mut inflight| {
if inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
{
inflight.remove(key);
true
} else {
false
}
})
.unwrap_or(false);
if removed_current {
state.fail(error);
}
}
fn detach_all_loads(&self) {
let states = self
.inflight
.lock()
.map(|mut inflight| inflight.drain().map(|(_, state)| state).collect::<Vec<_>>())
.unwrap_or_default();
for state in states {
state.complete();
}
}
pub(crate) fn clear(&self) {
let Ok(_mutation) = self.mutation.lock() else {
return;
};
self.generation.fetch_add(1, Ordering::AcqRel);
self.entries.clear();
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();
}
self.detach_all_loads();
}
}
#[cfg(test)]
mod tests {
use super::{SystemConfigCache, SystemConfigInflightRegistration};
use crate::GatewayError;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
#[tokio::test]
async fn singleflight_notifies_only_followers_for_the_matching_key() {
let cache = SystemConfigCache::default();
let leader_a = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("key-a should register a leader"),
};
let leader_b = match cache.register_load("key-b") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("key-b should register a leader"),
};
let follower_a = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second key-a registration should follow"),
};
let follower_b = match cache.register_load("key-b") {
SystemConfigInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second key-b registration should follow"),
};
drop(leader_b);
tokio::time::timeout(Duration::from_millis(100), follower_b.wait())
.await
.expect("key-b follower should wake when key-b completes")
.expect("successful load should not publish an error");
assert!(
tokio::time::timeout(Duration::from_millis(20), follower_a.wait())
.await
.is_err(),
"key-a follower must not wake for unrelated key-b"
);
drop(leader_a);
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn leader_completion_before_follower_poll_does_not_lose_wakeup() {
let cache = SystemConfigCache::default();
let leader = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
drop(leader);
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("completion before first poll must release the follower")
.expect("successful load should not publish an error");
}
#[tokio::test]
async fn failed_load_is_shared_and_old_guard_preserves_replacement() {
let cache = SystemConfigCache::default();
let old_leader = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
old_leader.fail(GatewayError::Internal(
"forced system config load failure".to_string(),
));
let error = tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("failed load should release its follower")
.expect_err("follower should observe the leader error");
assert_eq!(error.into_message(), "forced system config load failure");
let replacement = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("failed load should allow a replacement"),
};
drop(old_leader);
assert!(matches!(
cache.register_load("key-a"),
SystemConfigInflightRegistration::Follower(_)
));
drop(replacement);
}
#[tokio::test]
async fn application_write_wins_over_detached_load_failure() {
let cache = SystemConfigCache::default();
let old_leader = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
cache.insert(
"key-a".to_string(),
Some(json!("written")),
Duration::from_secs(60),
);
old_leader.fail(GatewayError::Internal(
"superseded system config load failure".to_string(),
));
follower
.wait()
.await
.expect("detached followers should observe the application write");
assert_eq!(
cache
.get_with_age("key-a", Duration::from_secs(60))
.map(|(value, _)| value),
Some(Some(json!("written")))
);
}
#[test]
fn clear_rejects_old_publication_and_old_guard_preserves_replacement() {
let cache = SystemConfigCache::default();
let old_leader = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_generation = old_leader.generation();
cache.clear();
let replacement = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("clear should allow an immediate replacement"),
};
assert!(!cache.insert_if_generation(
"key-a".to_string(),
Some(json!("stale")),
Duration::from_secs(60),
old_generation,
));
drop(old_leader);
assert!(matches!(
cache.register_load("key-a"),
SystemConfigInflightRegistration::Follower(_)
));
drop(replacement);
assert!(matches!(
cache.register_load("key-a"),
SystemConfigInflightRegistration::Leader(_)
));
}
#[test]
fn owned_refresh_allows_only_one_leader_per_key() {
let cache = Arc::new(SystemConfigCache::default());
let leader = cache
.try_register_owned_leader("key-a")
.expect("first refresh should lead");
assert!(cache.try_register_owned_leader("key-a").is_none());
assert!(cache.try_register_owned_leader("key-b").is_some());
drop(leader);
assert!(cache.try_register_owned_leader("key-a").is_some());
}
#[test]
fn application_write_rejects_an_older_refresh() {
let cache = SystemConfigCache::default();
let old_leader = match cache.register_load("key-a") {
SystemConfigInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_generation = old_leader.generation();
cache.insert(
"key-a".to_string(),
Some(json!("written")),
Duration::from_secs(60),
);
assert!(!cache.insert_if_generation(
"key-a".to_string(),
Some(json!("old-refresh")),
Duration::from_secs(60),
old_generation,
));
assert_eq!(
cache
.get_with_age("key-a", Duration::from_secs(60))
.map(|(value, _)| value),
Some(Some(json!("written")))
);
}
}
+249 -5
View File
@@ -4,9 +4,15 @@ use axum::http::Uri;
use super::super::GatewayControlDecision;
use super::credentials::{contains_string, extract_requested_model};
use super::GatewayControlAuthContext;
use crate::stage_metrics::observe_gateway_stage_ms;
use crate::{AppState, GatewayError};
const DAILY_QUOTA_EPSILON_USD: f64 = 0.000_000_01;
const AUTH_PRICING_VALIDATION_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(5);
// Billing mutations clear this cache locally. The bounded stale window leaves
// room for cross-node propagation without synchronously reloading at every
// short TTL boundary.
const AUTH_CAPACITY_CACHE_STALE_TTL: std::time::Duration = std::time::Duration::from_secs(60);
#[derive(Debug, Clone, PartialEq)]
pub(crate) enum GatewayLocalAuthRejection {
@@ -96,6 +102,23 @@ pub(crate) async fn execution_plan_balance_capacity_rejection(
decision: &GatewayControlDecision,
plan: &aether_contracts::ExecutionPlan,
report_context: Option<&serde_json::Value>,
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
let started_at = std::time::Instant::now();
let result =
execution_plan_balance_capacity_rejection_inner(state, decision, plan, report_context)
.await;
observe_gateway_stage_ms(
"auth_capacity_total",
started_at.elapsed().as_millis() as u64,
);
result
}
async fn execution_plan_balance_capacity_rejection_inner(
state: &AppState,
decision: &GatewayControlDecision,
plan: &aether_contracts::ExecutionPlan,
report_context: Option<&serde_json::Value>,
) -> Result<Option<GatewayLocalAuthRejection>, GatewayError> {
let Some(auth_context) = decision.auth_context.as_ref() else {
return Ok(None);
@@ -154,17 +177,29 @@ async fn available_balance_capacity_usd(
state: &AppState,
auth_context: &GatewayControlAuthContext,
) -> Result<Option<f64>, GatewayError> {
let quota = state
let quota_started_at = std::time::Instant::now();
let quota_result = state
.find_user_daily_quota_availability_for_auth(&auth_context.user_id)
.await?
.filter(|quota| quota.has_active_daily_quota);
let wallet = state
.await;
observe_gateway_stage_ms(
"auth_capacity_quota",
quota_started_at.elapsed().as_millis() as u64,
);
let quota = quota_result?.filter(|quota| quota.has_active_daily_quota);
let wallet_started_at = std::time::Instant::now();
let wallet_result = state
.read_wallet_snapshot_for_auth(
&auth_context.user_id,
&auth_context.api_key_id,
auth_context.api_key_is_standalone,
)
.await?;
.await;
observe_gateway_stage_ms(
"auth_capacity_wallet",
wallet_started_at.elapsed().as_millis() as u64,
);
let wallet = wallet_result?;
let wallet_available_usd = wallet.as_ref().and_then(wallet_finite_available_usd);
let wallet_is_unlimited = wallet
.as_ref()
@@ -193,6 +228,21 @@ async fn estimate_execution_plan_cost_upper_bound_usd(
state: &AppState,
plan: &aether_contracts::ExecutionPlan,
report_context: Option<&serde_json::Value>,
) -> Result<Option<f64>, GatewayError> {
let started_at = std::time::Instant::now();
let result =
estimate_execution_plan_cost_upper_bound_usd_inner(state, plan, report_context).await;
observe_gateway_stage_ms(
"auth_capacity_cost_estimate",
started_at.elapsed().as_millis() as u64,
);
result
}
async fn estimate_execution_plan_cost_upper_bound_usd_inner(
state: &AppState,
plan: &aether_contracts::ExecutionPlan,
report_context: Option<&serde_json::Value>,
) -> Result<Option<f64>, GatewayError> {
let api_format = crate::ai_serving::normalize_api_format_alias(&plan.provider_api_format);
let body_json = plan.body.json_body.as_ref();
@@ -346,10 +396,101 @@ async fn validate_execution_plan_pricing_for_unavailable_estimate(
model_id: Option<&str>,
global_model_name: Option<&str>,
requested_processing_tier: Option<&str>,
) -> Result<(), GatewayError> {
let started_at = std::time::Instant::now();
let result = validate_execution_plan_pricing_for_unavailable_estimate_inner(
state,
plan,
model_id,
global_model_name,
requested_processing_tier,
)
.await;
observe_gateway_stage_ms(
"auth_capacity_pricing_validation",
started_at.elapsed().as_millis() as u64,
);
result
}
async fn validate_execution_plan_pricing_for_unavailable_estimate_inner(
state: &AppState,
plan: &aether_contracts::ExecutionPlan,
model_id: Option<&str>,
global_model_name: Option<&str>,
requested_processing_tier: Option<&str>,
) -> Result<(), GatewayError> {
if model_id.is_none() && global_model_name.is_none() {
return Ok(());
}
let capacity_ttl = state.frontdoor_runtime_guards.auth_capacity_cache_ttl;
if capacity_ttl.is_zero() {
return validate_execution_plan_pricing_uncached(
state,
plan,
model_id,
global_model_name,
requested_processing_tier,
)
.await;
}
let ttl = capacity_ttl.max(AUTH_PRICING_VALIDATION_CACHE_TTL);
let cache_key = execution_plan_pricing_validation_cache_key(
plan,
model_id,
global_model_name,
requested_processing_tier,
);
let cache = state.auth_request_cost_upper_bound_cache.clone();
cache
.get_or_load_once_stale_while_revalidating(
cache_key,
ttl,
AUTH_CAPACITY_CACHE_STALE_TTL,
|| async {
validate_execution_plan_pricing_uncached(
state,
plan,
model_id,
global_model_name,
requested_processing_tier,
)
.await?;
Ok::<Option<f64>, GatewayError>(Some(0.0))
},
|| {
let state = state.clone();
let plan = plan.clone();
let model_id = model_id.map(ToOwned::to_owned);
let global_model_name = global_model_name.map(ToOwned::to_owned);
let requested_processing_tier = requested_processing_tier.map(ToOwned::to_owned);
async move {
validate_execution_plan_pricing_uncached(
&state,
&plan,
model_id.as_deref(),
global_model_name.as_deref(),
requested_processing_tier.as_deref(),
)
.await?;
Ok::<Option<f64>, GatewayError>(Some(0.0))
}
},
crate::cache::CacheLoadObserver::default(),
)
.await?;
Ok(())
}
async fn validate_execution_plan_pricing_uncached(
state: &AppState,
plan: &aether_contracts::ExecutionPlan,
model_id: Option<&str>,
global_model_name: Option<&str>,
requested_processing_tier: Option<&str>,
) -> Result<(), GatewayError> {
let _permit = state.acquire_auth_snapshot_load_gate().await?;
let Some(context) =
load_execution_plan_billing_context(state, plan, model_id, global_model_name).await?
@@ -417,6 +558,22 @@ fn execution_plan_cost_upper_bound_cache_key(
)
}
fn execution_plan_pricing_validation_cache_key(
plan: &aether_contracts::ExecutionPlan,
model_id: Option<&str>,
global_model_name: Option<&str>,
requested_processing_tier: Option<&str>,
) -> String {
format!(
"pricing-validation\x1f{}\x1f{}\x1f{}\x1f{}\x1f{}",
plan.provider_id,
plan.key_id,
model_id.unwrap_or(""),
global_model_name.unwrap_or(""),
requested_processing_tier.unwrap_or("standard"),
)
}
fn authorization_task_type<'a>(
api_format: &str,
report_context: Option<&'a serde_json::Value>,
@@ -1501,6 +1658,93 @@ mod tests {
assert_eq!(quota_calls.load(Ordering::Acquire), 2);
}
#[tokio::test]
async fn standalone_auth_capacity_reuses_pricing_validation_within_ttl() {
let context = billing_context_with_pricing(
Some(json!({
"tiers": [{
"up_to": null,
"input_price_per_1m": 1.0,
"output_price_per_1m": 2.0
}]
})),
None,
None,
None,
);
let quota_calls = Arc::new(AtomicUsize::new(0));
let model_context_calls = Arc::new(AtomicUsize::new(0));
let candidate_repository =
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
sample_row(),
]));
let billing_repository = Arc::new(FixedBillingReadRepository::with_counters(
quota_availability(1.0, true),
context,
Arc::clone(&quota_calls),
Arc::clone(&model_context_calls),
));
let data = GatewayDataState::with_minimal_candidate_selection_and_billing_for_tests(
candidate_repository,
billing_repository,
);
let state = AppState::new()
.expect("state should build")
.with_data_state_for_tests(data);
let mut decision = decision_with_allowed_models(vec!["gpt-5".to_string()]);
decision
.auth_context
.as_mut()
.expect("auth context should exist")
.api_key_is_standalone = true;
let plan = execution_plan(
json!({
"model": "gpt-5",
"messages": [{"role": "user", "content": "hi"}]
}),
"openai:chat",
);
let report_context = billing_report_context();
for _ in 0..2 {
assert_eq!(
execution_plan_balance_capacity_rejection(
&state,
&decision,
&plan,
Some(&report_context),
)
.await
.expect("standalone pricing validation should resolve"),
None
);
}
assert_eq!(quota_calls.load(Ordering::Acquire), 0);
assert_eq!(model_context_calls.load(Ordering::Acquire), 1);
let cache_key = super::execution_plan_pricing_validation_cache_key(
&plan,
Some("model-1"),
Some("gpt-5"),
None,
);
assert_eq!(
state.auth_request_cost_upper_bound_cache.get(
&cache_key,
state.frontdoor_runtime_guards.auth_capacity_cache_ttl,
),
Some(Some(0.0))
);
state.invalidate_provider_routing_caches();
assert_eq!(
state.auth_request_cost_upper_bound_cache.get(
&cache_key,
state.frontdoor_runtime_guards.auth_capacity_cache_ttl,
),
None
);
}
#[tokio::test]
async fn admin_bypass_limits_does_not_skip_unbounded_zero_balance_capacity() {
let context = billing_context_with_pricing(
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
@@ -5,14 +7,26 @@ use aether_cache::ExpiringMap;
use aether_data::repository::auth::*;
use aether_data::DataLayerError;
use async_trait::async_trait;
use tokio::sync::futures::OwnedNotified;
use tokio::sync::Notify;
// Security revalidation bypasses this throughput-oriented read cache.
const AUTH_API_KEY_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(30);
const AUTH_API_KEY_SNAPSHOT_CACHE_MAX_ENTRIES: usize = 16_384;
tokio::task_local! {
static AUTH_API_KEY_READ_CACHE_BYPASS: ();
}
pub(super) struct CachedAuthApiKeyReadRepository {
inner: Arc<dyn AuthApiKeyReadRepository>,
snapshots: ExpiringMap<AuthApiKeySnapshotCacheKey, Option<StoredAuthApiKeySnapshot>>,
load_guard: tokio::sync::Mutex<()>,
// Loads for unrelated identities must not queue behind one global mutex. A
// per-key notification keeps same-key loads singleflight without retaining
// an async mutex (or a cancelled waiter) in the map.
inflight: std::sync::Mutex<HashMap<AuthApiKeySnapshotCacheKey, Arc<AuthApiKeyInflightState>>>,
generation: AtomicU64,
mutation: std::sync::Mutex<()>,
}
impl CachedAuthApiKeyReadRepository {
@@ -20,7 +34,68 @@ impl CachedAuthApiKeyReadRepository {
Self {
inner,
snapshots: ExpiringMap::new(),
load_guard: tokio::sync::Mutex::new(()),
inflight: std::sync::Mutex::new(HashMap::new()),
generation: AtomicU64::new(0),
mutation: std::sync::Mutex::new(()),
}
}
fn insert_if_generation(
&self,
cache_key: AuthApiKeySnapshotCacheKey,
value: Option<StoredAuthApiKeySnapshot>,
generation: u64,
) {
let Ok(_mutation) = self.mutation.lock() else {
return;
};
if self.generation.load(Ordering::Acquire) != generation {
return;
}
self.snapshots.insert(
cache_key,
value,
AUTH_API_KEY_SNAPSHOT_CACHE_TTL,
AUTH_API_KEY_SNAPSHOT_CACHE_MAX_ENTRIES,
);
}
pub(super) fn clear_cache(&self) {
let Ok(_mutation) = self.mutation.lock() else {
return;
};
self.generation.fetch_add(1, Ordering::AcqRel);
self.snapshots.clear();
let states = self
.inflight
.lock()
.map(|mut inflight| inflight.drain().map(|(_, state)| state).collect::<Vec<_>>())
.unwrap_or_default();
for state in states {
state.complete();
}
}
fn register_inflight(
&self,
cache_key: &AuthApiKeySnapshotCacheKey,
) -> AuthApiKeyInflightRegistration<'_> {
match self.inflight.lock() {
Ok(mut inflight) => {
if let Some(state) = inflight.get(cache_key) {
AuthApiKeyInflightRegistration::Follower(state.waiter())
} else {
let state = Arc::new(AuthApiKeyInflightState::new());
inflight.insert(cache_key.clone(), Arc::clone(&state));
AuthApiKeyInflightRegistration::Leader(AuthApiKeyInflightGuard {
cache: self,
cache_key: Some(cache_key.clone()),
state,
generation: self.generation.load(Ordering::Acquire),
})
}
}
Err(_) => AuthApiKeyInflightRegistration::Bypass,
}
}
@@ -43,6 +118,54 @@ impl CachedAuthApiKeyReadRepository {
}
}
impl super::GatewayDataState {
pub(crate) fn clear_auth_api_key_read_cache(&self) {
if let Some(repository) = self.auth_api_key_reader.as_ref() {
repository.clear_cache();
}
}
#[cfg(test)]
pub(crate) fn with_cached_auth_api_key_repository_for_tests<T>(repository: Arc<T>) -> Self
where
T: AuthRepository + 'static,
{
let inner: Arc<dyn AuthApiKeyReadRepository> = repository.clone();
let cached: Arc<dyn AuthApiKeyReadRepository> =
Arc::new(CachedAuthApiKeyReadRepository::new(inner));
let mut state = Self::with_auth_api_key_repository_for_tests(repository);
state.auth_api_key_reader = Some(cached);
state
}
pub(crate) async fn read_auth_api_key_snapshot_strong(
&self,
user_id: &str,
api_key_id: &str,
now_unix_secs: u64,
) -> Result<Option<crate::data::auth::GatewayAuthApiKeySnapshot>, DataLayerError> {
AUTH_API_KEY_READ_CACHE_BYPASS
.scope(
(),
self.read_auth_api_key_snapshot(user_id, api_key_id, now_unix_secs),
)
.await
}
pub(crate) async fn read_auth_api_key_snapshot_by_key_hash_strong(
&self,
key_hash: &str,
now_unix_secs: u64,
) -> Result<Option<crate::data::auth::GatewayAuthApiKeySnapshot>, DataLayerError> {
AUTH_API_KEY_READ_CACHE_BYPASS
.scope(
(),
self.read_auth_api_key_snapshot_by_key_hash(key_hash, now_unix_secs),
)
.await
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum AuthApiKeySnapshotCacheKey {
KeyHash(String),
@@ -50,12 +173,142 @@ enum AuthApiKeySnapshotCacheKey {
UserApiKeyIds { user_id: String, api_key_id: String },
}
enum AuthApiKeyInflightRegistration<'a> {
Leader(AuthApiKeyInflightGuard<'a>),
Follower(AuthApiKeyInflightWaiter),
Bypass,
}
struct AuthApiKeyInflightState {
completed: AtomicBool,
error: std::sync::Mutex<Option<DataLayerError>>,
notify: Arc<Notify>,
}
impl AuthApiKeyInflightState {
fn new() -> Self {
Self {
completed: AtomicBool::new(false),
error: std::sync::Mutex::new(None),
notify: Arc::new(Notify::new()),
}
}
fn waiter(self: &Arc<Self>) -> AuthApiKeyInflightWaiter {
AuthApiKeyInflightWaiter {
state: Arc::clone(self),
notified: Arc::clone(&self.notify).notified_owned(),
}
}
fn complete(&self) {
if !self.completed.swap(true, Ordering::AcqRel) {
self.notify.notify_waiters();
}
}
fn fail(&self, error: DataLayerError) {
if let Ok(mut current) = self.error.lock() {
*current = Some(error);
}
self.complete();
}
fn error(&self) -> Option<DataLayerError> {
self.error.lock().ok().and_then(|error| error.clone())
}
}
struct AuthApiKeyInflightWaiter {
state: Arc<AuthApiKeyInflightState>,
notified: OwnedNotified,
}
impl AuthApiKeyInflightWaiter {
async fn wait(self) -> Result<(), DataLayerError> {
let Self { state, notified } = self;
if state.completed.load(Ordering::Acquire) {
return state.error().map_or(Ok(()), Err);
}
tokio::pin!(notified);
if notified.as_mut().enable() || state.completed.load(Ordering::Acquire) {
return state.error().map_or(Ok(()), Err);
}
notified.await;
state.error().map_or(Ok(()), Err)
}
}
struct AuthApiKeyInflightGuard<'a> {
cache: &'a CachedAuthApiKeyReadRepository,
cache_key: Option<AuthApiKeySnapshotCacheKey>,
state: Arc<AuthApiKeyInflightState>,
generation: u64,
}
impl AuthApiKeyInflightGuard<'_> {
fn fail(&self, error: DataLayerError) {
let Some(cache_key) = self.cache_key.as_ref() else {
return;
};
let removed_current = self
.cache
.inflight
.lock()
.map(|mut inflight| {
if inflight
.get(cache_key)
.is_some_and(|current| Arc::ptr_eq(current, &self.state))
{
inflight.remove(cache_key);
true
} else {
false
}
})
.unwrap_or(false);
if removed_current {
self.state.fail(error);
}
}
}
impl Drop for AuthApiKeyInflightGuard<'_> {
fn drop(&mut self) {
let Some(cache_key) = self.cache_key.take() else {
return;
};
let removed = self
.cache
.inflight
.lock()
.map(|mut inflight| {
inflight
.get(&cache_key)
.is_some_and(|current| Arc::ptr_eq(current, &self.state))
&& inflight.remove(&cache_key).is_some()
})
.unwrap_or(false);
if removed {
self.state.complete();
}
}
}
#[async_trait]
impl AuthApiKeyReadRepository for CachedAuthApiKeyReadRepository {
async fn find_api_key_snapshot(
&self,
key: AuthApiKeyLookupKey<'_>,
) -> Result<Option<StoredAuthApiKeySnapshot>, DataLayerError> {
if AUTH_API_KEY_READ_CACHE_BYPASS
.try_with(|_| true)
.unwrap_or(false)
{
return self.inner.find_api_key_snapshot(key).await;
}
let cache_key = Self::cache_key(key);
if let Some(value) = self
.snapshots
@@ -64,22 +317,43 @@ impl AuthApiKeyReadRepository for CachedAuthApiKeyReadRepository {
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);
}
loop {
match self.register_inflight(&cache_key) {
AuthApiKeyInflightRegistration::Leader(guard) => {
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)
let value = match self.inner.find_api_key_snapshot(key).await {
Ok(value) => value,
Err(error) => {
guard.fail(error.clone());
return Err(error);
}
};
self.insert_if_generation(cache_key.clone(), value.clone(), guard.generation);
return Ok(value);
}
AuthApiKeyInflightRegistration::Follower(waiter) => {
waiter.wait().await?;
if let Some(value) = self
.snapshots
.get_fresh(&cache_key, AUTH_API_KEY_SNAPSHOT_CACHE_TTL)
{
return Ok(value);
}
}
AuthApiKeyInflightRegistration::Bypass => {
let generation = self.generation.load(Ordering::Acquire);
let value = self.inner.find_api_key_snapshot(key).await?;
self.insert_if_generation(cache_key.clone(), value.clone(), generation);
return Ok(value);
}
}
}
}
async fn list_api_key_snapshots_by_ids(
@@ -98,6 +372,10 @@ impl AuthApiKeyReadRepository for CachedAuthApiKeyReadRepository {
Ok(snapshots)
}
fn clear_cache(&self) {
CachedAuthApiKeyReadRepository::clear_cache(self);
}
async fn list_export_api_keys_by_user_ids(
&self,
user_ids: &[String],
@@ -178,3 +456,262 @@ impl AuthApiKeyReadRepository for CachedAuthApiKeyReadRepository {
self.inner.list_export_standalone_api_keys().await
}
}
#[cfg(test)]
mod tests {
use super::*;
use aether_data::repository::auth::InMemoryAuthApiKeySnapshotRepository;
fn sample_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
StoredAuthApiKeySnapshot::new(
user_id.to_string(),
"alice".to_string(),
Some("alice@example.com".to_string()),
"user".to_string(),
"local".to_string(),
true,
false,
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-4.1"])),
api_key_id.to_string(),
Some("default".to_string()),
true,
false,
false,
Some(60),
Some(5),
Some(200),
Some(serde_json::json!(["openai"])),
Some(serde_json::json!(["openai:chat"])),
Some(serde_json::json!(["gpt-4.1"])),
)
.expect("snapshot should build")
}
#[tokio::test]
async fn concurrent_same_key_loads_once_and_reuses_cached_snapshot() {
let inner = Arc::new(
InMemoryAuthApiKeySnapshotRepository::seed([(
None,
sample_snapshot("key-a", "user-a"),
)])
.with_lookup_delay_for_tests(Duration::from_millis(25)),
);
let repository = Arc::new(CachedAuthApiKeyReadRepository::new(inner.clone()));
let mut tasks = Vec::new();
for _ in 0..32 {
let repository = Arc::clone(&repository);
tasks.push(tokio::spawn(async move {
repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("key-a"))
.await
.expect("cached lookup should succeed")
.expect("snapshot should exist")
}));
}
for task in tasks {
assert_eq!(
task.await.expect("lookup task should join").api_key_id,
"key-a"
);
}
assert_eq!(inner.snapshot_lookup_count("key-a"), 1);
assert!(repository
.inflight
.lock()
.expect("inflight lock should not be poisoned")
.is_empty());
}
#[tokio::test]
async fn different_keys_use_independent_inflight_notifications() {
let inner = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed([]));
let repository = CachedAuthApiKeyReadRepository::new(inner);
let key_a = AuthApiKeySnapshotCacheKey::ApiKeyId("key-a".to_string());
let key_b = AuthApiKeySnapshotCacheKey::ApiKeyId("key-b".to_string());
let leader_a = repository.register_inflight(&key_a);
let follower_a = repository.register_inflight(&key_a);
let leader_b = repository.register_inflight(&key_b);
assert!(matches!(
leader_a,
AuthApiKeyInflightRegistration::Leader(_)
));
assert!(matches!(
follower_a,
AuthApiKeyInflightRegistration::Follower(_)
));
assert!(matches!(
leader_b,
AuthApiKeyInflightRegistration::Leader(_)
));
}
#[tokio::test]
async fn cancelled_leader_releases_inflight_key() {
let inner = Arc::new(
InMemoryAuthApiKeySnapshotRepository::seed([])
.with_lookup_delay_for_tests(Duration::from_secs(30)),
);
let repository = Arc::new(CachedAuthApiKeyReadRepository::new(inner));
let lookup_repository = Arc::clone(&repository);
let task = tokio::spawn(async move {
lookup_repository
.find_api_key_snapshot(AuthApiKeyLookupKey::ApiKeyId("cancelled-key"))
.await
});
tokio::time::timeout(Duration::from_secs(1), async {
loop {
if repository
.inflight
.lock()
.expect("inflight lock should not be poisoned")
.contains_key(&AuthApiKeySnapshotCacheKey::ApiKeyId(
"cancelled-key".to_string(),
))
{
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("lookup should register its inflight key");
task.abort();
let _ = task.await;
assert!(repository
.inflight
.lock()
.expect("inflight lock should not be poisoned")
.is_empty());
}
#[tokio::test]
async fn follower_observes_completion_before_first_poll() {
let inner = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed([]));
let repository = CachedAuthApiKeyReadRepository::new(inner);
let key = AuthApiKeySnapshotCacheKey::ApiKeyId("key-a".to_string());
let leader = match repository.register_inflight(&key) {
AuthApiKeyInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match repository.register_inflight(&key) {
AuthApiKeyInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
// Complete before the OwnedNotified is polled. A bare notify_waiters()
// broadcast would be lost in this ordering.
drop(leader);
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("completed follower must not miss the broadcast")
.expect("successful flight should not publish an error");
}
#[tokio::test]
async fn failed_flight_shares_error_and_preserves_replacement() {
let inner = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed([]));
let repository = CachedAuthApiKeyReadRepository::new(inner);
let key = AuthApiKeySnapshotCacheKey::ApiKeyId("key-a".to_string());
let old_leader = match repository.register_inflight(&key) {
AuthApiKeyInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match repository.register_inflight(&key) {
AuthApiKeyInflightRegistration::Follower(waiter) => waiter,
_ => panic!("second registration should follow"),
};
old_leader.fail(DataLayerError::Sql(
"forced auth snapshot load failure".to_string(),
));
let error = tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("failed flight should release its follower")
.expect_err("follower should observe the leader error");
assert_eq!(
error.to_string(),
"sql error: forced auth snapshot load failure"
);
let replacement = match repository.register_inflight(&key) {
AuthApiKeyInflightRegistration::Leader(guard) => guard,
_ => panic!("failed flight should allow a replacement"),
};
drop(old_leader);
assert!(matches!(
repository.register_inflight(&key),
AuthApiKeyInflightRegistration::Follower(_)
));
drop(replacement);
}
#[tokio::test]
async fn strong_read_bypasses_a_fresh_cached_allow_snapshot() {
let inner = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed([(
None,
sample_snapshot("key-a", "user-a"),
)]));
let repository = CachedAuthApiKeyReadRepository::new(inner.clone());
let lookup = AuthApiKeyLookupKey::UserApiKeyIds {
user_id: "user-a",
api_key_id: "key-a",
};
let cached = repository
.find_api_key_snapshot(lookup)
.await
.expect("initial lookup should succeed")
.expect("snapshot should exist");
assert!(!cached.api_key_is_locked);
assert!(inner
.set_user_api_key_locked("user-a", "key-a", true)
.await
.expect("cross-node lock should succeed"));
let still_cached = repository
.find_api_key_snapshot(lookup)
.await
.expect("cached lookup should succeed")
.expect("snapshot should exist");
assert!(!still_cached.api_key_is_locked);
let strong = AUTH_API_KEY_READ_CACHE_BYPASS
.scope((), repository.find_api_key_snapshot(lookup))
.await
.expect("strong lookup should succeed")
.expect("snapshot should exist");
assert!(strong.api_key_is_locked);
assert_eq!(inner.snapshot_lookup_count("key-a"), 2);
}
#[test]
fn clear_cache_rejects_old_leader_publication() {
let inner = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed([]));
let repository = CachedAuthApiKeyReadRepository::new(inner);
let key = AuthApiKeySnapshotCacheKey::ApiKeyId("key-a".to_string());
let leader = match repository.register_inflight(&key) {
AuthApiKeyInflightRegistration::Leader(guard) => guard,
_ => panic!("key-a should register a leader"),
};
let old_generation = leader.generation;
repository.clear_cache();
repository.insert_if_generation(
key.clone(),
Some(sample_snapshot("stale-key", "user-a")),
old_generation,
);
assert!(repository
.snapshots
.get_fresh(&key, AUTH_API_KEY_SNAPSHOT_CACHE_TTL)
.is_none());
}
}
@@ -1,6 +1,6 @@
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
@@ -12,12 +12,13 @@ use aether_data_contracts::repository::candidate_selection::{
StoredPoolKeyCandidateRowsQuery, StoredRequestedModelCandidateRowsQuery,
};
use async_trait::async_trait;
use tokio::sync::Notify;
use tokio::sync::{Notify, OwnedSemaphorePermit, Semaphore};
use tokio::time::timeout;
use tracing::warn;
const CANDIDATE_SELECTION_CACHE_TTL: Duration = Duration::from_secs(5);
const CANDIDATE_SELECTION_CACHE_MAX_ENTRIES: usize = 4096;
const CANDIDATE_SELECTION_CACHE_MAX_INFLIGHT: usize = 4096;
#[cfg(not(test))]
const CANDIDATE_SELECTION_CACHE_LOAD_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(test)]
@@ -30,10 +31,10 @@ const CANDIDATE_SELECTION_CACHE_INFLIGHT_WAIT_TIMEOUT: Duration = Duration::from
pub(super) struct CachedMinimalCandidateSelectionReadRepository {
inner: Arc<dyn MinimalCandidateSelectionReadRepository>,
entries: ExpiringMap<CandidateSelectionCacheKey, Vec<StoredMinimalCandidateSelectionRow>>,
inflight: Mutex<HashMap<CandidateSelectionCacheKey, u64>>,
inflight_notify: Notify,
next_inflight_token: AtomicU64,
inflight: Mutex<HashMap<CandidateSelectionCacheKey, Arc<InflightState>>>,
epoch: AtomicU64,
mutation: Mutex<()>,
admission: Arc<Semaphore>,
}
impl CachedMinimalCandidateSelectionReadRepository {
@@ -42,9 +43,9 @@ impl CachedMinimalCandidateSelectionReadRepository {
inner,
entries: ExpiringMap::new(),
inflight: Mutex::new(HashMap::new()),
inflight_notify: Notify::new(),
next_inflight_token: AtomicU64::new(1),
epoch: AtomicU64::new(0),
mutation: Mutex::new(()),
admission: Arc::new(Semaphore::new(CANDIDATE_SELECTION_CACHE_MAX_INFLIGHT)),
}
}
@@ -61,18 +62,35 @@ impl CachedMinimalCandidateSelectionReadRepository {
return Ok(rows);
}
self.load_after_cache_miss(key, load).await
}
async fn load_after_cache_miss<F, Fut>(
&self,
key: CandidateSelectionCacheKey,
load: F,
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError>
where
F: Fn() -> Fut,
Fut: Future<Output = Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError>>,
{
loop {
let notified = self.inflight_notify.notified();
match self.register_inflight(&key) {
InflightRegistration::Bypass => {
return load_candidate_selection_rows_with_timeout(&key, load()).await;
InflightRegistration::Saturated => {
return Err(DataLayerError::TimedOut(format!(
"candidate selection cache admission saturated for {key:?}"
)));
}
InflightRegistration::Follower => {
if timeout(CANDIDATE_SELECTION_CACHE_INFLIGHT_WAIT_TIMEOUT, notified)
.await
.is_err()
InflightRegistration::Follower(state) => {
match timeout(
CANDIDATE_SELECTION_CACHE_INFLIGHT_WAIT_TIMEOUT,
state.wait(),
)
.await
{
self.expire_inflight(&key);
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(_) => self.expire_inflight(&key, &state),
}
if let Some(rows) = self.entries.get_fresh(&key, CANDIDATE_SELECTION_CACHE_TTL)
{
@@ -80,60 +98,107 @@ impl CachedMinimalCandidateSelectionReadRepository {
}
continue;
}
InflightRegistration::Leader(token) => {
let mut guard = InflightGuard::new(self, key.clone(), token);
let load_epoch = self.epoch.load(Ordering::Acquire);
InflightRegistration::Leader(mut guard) => {
// A writer may have populated the cache after the first
// miss but before this flight was registered.
if let Some(rows) = self.entries.get_fresh(&key, CANDIDATE_SELECTION_CACHE_TTL)
{
return Ok(rows);
}
let result = load_candidate_selection_rows_with_timeout(&key, load()).await;
if let Ok(rows) = &result {
if load_epoch == self.epoch.load(Ordering::Acquire) {
self.entries.insert(
key.clone(),
rows.clone(),
CANDIDATE_SELECTION_CACHE_TTL,
CANDIDATE_SELECTION_CACHE_MAX_ENTRIES,
);
}
match &result {
Ok(rows) => guard.finish_loaded(rows.clone()),
Err(error) => guard.finish(Some(error.clone())),
}
guard.finish();
return result;
}
}
}
}
fn register_inflight(&self, key: &CandidateSelectionCacheKey) -> InflightRegistration {
match self.inflight.lock() {
Ok(mut inflight) => {
if inflight.contains_key(key) {
return InflightRegistration::Follower;
}
let token = self.next_inflight_token.fetch_add(1, Ordering::AcqRel);
inflight.insert(key.clone(), token);
InflightRegistration::Leader(token)
}
Err(_) => InflightRegistration::Bypass,
fn register_inflight(&self, key: &CandidateSelectionCacheKey) -> InflightRegistration<'_> {
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return InflightRegistration::Follower(Arc::clone(state));
}
if inflight.len() >= CANDIDATE_SELECTION_CACHE_MAX_INFLIGHT {
return InflightRegistration::Saturated;
}
let Ok(admission) = Arc::clone(&self.admission).try_acquire_owned() else {
return InflightRegistration::Saturated;
};
let state = Arc::new(InflightState {
epoch: self.epoch.load(Ordering::Acquire),
notify: Notify::new(),
completed: AtomicBool::new(false),
error: Mutex::new(None),
});
inflight.insert(key.clone(), Arc::clone(&state));
InflightRegistration::Leader(InflightGuard::new(self, key.clone(), state, admission))
}
fn finish_inflight(&self, key: &CandidateSelectionCacheKey, token: u64) {
let mut removed = false;
if let Ok(mut inflight) = self.inflight.lock() {
if inflight.get(key).copied() == Some(token) {
inflight.remove(key);
removed = true;
}
fn finish_inflight(
&self,
key: &CandidateSelectionCacheKey,
state: &Arc<InflightState>,
rows: Option<Vec<StoredMinimalCandidateSelectionRow>>,
admission: Option<OwnedSemaphorePermit>,
) -> Option<Arc<InflightState>> {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
drop(admission);
if !inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
|| self.epoch.load(Ordering::Acquire) != state.epoch
{
return None;
}
if removed {
self.inflight_notify.notify_waiters();
if let Some(rows) = rows {
self.entries.insert(
key.clone(),
rows,
CANDIDATE_SELECTION_CACHE_TTL,
CANDIDATE_SELECTION_CACHE_MAX_ENTRIES,
);
}
let removed = inflight.remove(key);
drop(inflight);
drop(_mutation);
removed
}
fn expire_inflight(&self, key: &CandidateSelectionCacheKey) {
let mut removed = false;
if let Ok(mut inflight) = self.inflight.lock() {
removed = inflight.remove(key).is_some();
}
if removed {
fn expire_inflight(&self, key: &CandidateSelectionCacheKey, state: &Arc<InflightState>) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let removed = {
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
{
inflight.remove(key)
} else {
None
}
};
drop(_mutation);
if let Some(state) = removed {
warn!(
event_name = "candidate_selection_cache_inflight_expired",
log_type = "ops",
@@ -141,64 +206,143 @@ impl CachedMinimalCandidateSelectionReadRepository {
wait_timeout_ms = CANDIDATE_SELECTION_CACHE_INFLIGHT_WAIT_TIMEOUT.as_millis() as u64,
"gateway candidate selection cache expired stale inflight load"
);
self.inflight_notify.notify_waiters();
state.complete(None);
}
}
fn clear(&self) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.epoch.fetch_add(1, Ordering::AcqRel);
self.entries.clear();
let mut cleared_inflight = false;
if let Ok(mut inflight) = self.inflight.lock() {
cleared_inflight = !inflight.is_empty();
inflight.clear();
}
if cleared_inflight {
let states = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain()
.map(|(_, state)| state)
.collect::<Vec<_>>();
if !states.is_empty() {
warn!(
event_name = "candidate_selection_cache_inflight_cleared",
log_type = "ops",
"gateway candidate selection cache cleared in-flight loads"
);
self.inflight_notify.notify_waiters();
for state in states {
state.complete(None);
}
}
}
}
enum InflightRegistration {
Leader(u64),
Follower,
Bypass,
struct InflightState {
epoch: u64,
notify: Notify,
completed: AtomicBool,
error: Mutex<Option<DataLayerError>>,
}
impl InflightState {
fn complete(&self, error: Option<DataLayerError>) {
if let Some(error) = error {
if let Ok(mut current) = self.error.lock() {
*current = Some(error);
}
}
if !self.completed.swap(true, Ordering::AcqRel) {
self.notify.notify_waiters();
}
}
async fn wait(&self) -> Result<(), DataLayerError> {
loop {
if self.completed.load(Ordering::Acquire) {
return self
.error
.lock()
.ok()
.and_then(|error| error.clone())
.map_or(Ok(()), Err);
}
// Register before checking completion a second time. Creating a
// Notified future alone is insufficient because notify_waiters()
// can otherwise run before the future's first poll.
let mut notified = Box::pin(self.notify.notified());
notified.as_mut().enable();
if self.completed.load(Ordering::Acquire) {
return self
.error
.lock()
.ok()
.and_then(|error| error.clone())
.map_or(Ok(()), Err);
}
notified.await;
}
}
}
enum InflightRegistration<'a> {
Leader(InflightGuard<'a>),
Follower(Arc<InflightState>),
Saturated,
}
struct InflightGuard<'a> {
cache: &'a CachedMinimalCandidateSelectionReadRepository,
key: Option<CandidateSelectionCacheKey>,
token: u64,
state: Arc<InflightState>,
admission: Option<OwnedSemaphorePermit>,
}
impl<'a> InflightGuard<'a> {
fn new(
cache: &'a CachedMinimalCandidateSelectionReadRepository,
key: CandidateSelectionCacheKey,
token: u64,
state: Arc<InflightState>,
admission: OwnedSemaphorePermit,
) -> Self {
Self {
cache,
key: Some(key),
token,
state,
admission: Some(admission),
}
}
fn finish(&mut self) {
if let Some(key) = self.key.take() {
self.cache.finish_inflight(&key, self.token);
fn epoch(&self) -> u64 {
self.state.epoch
}
fn finish_loaded(&mut self, rows: Vec<StoredMinimalCandidateSelectionRow>) {
let removed = self.key.take().and_then(|key| {
self.cache
.finish_inflight(&key, &self.state, Some(rows), self.admission.take())
});
self.admission.take();
if let Some(removed) = removed {
removed.complete(None);
}
}
fn finish(&mut self, error: Option<DataLayerError>) {
let removed = self.key.take().and_then(|key| {
self.cache
.finish_inflight(&key, &self.state, None, self.admission.take())
});
self.admission.take();
if let Some(removed) = removed {
removed.complete(error);
}
}
}
impl Drop for InflightGuard<'_> {
fn drop(&mut self) {
self.finish();
self.finish(None);
}
}
@@ -573,6 +717,202 @@ mod tests {
assert_eq!(inner.calls(), 1);
}
#[tokio::test]
async fn candidate_selection_cache_only_notifies_matching_inflight_key() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key_a = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let key_b = CandidateSelectionCacheKey::ApiFormat {
api_format: "anthropic:messages".to_string(),
};
let mut leader_a = match cache.register_inflight(&key_a) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first key registration should lead"),
};
let mut leader_b = match cache.register_inflight(&key_b) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("second key registration should lead independently"),
};
let state_a = match cache.register_inflight(&key_a) {
InflightRegistration::Follower(state) => state,
_ => panic!("duplicate key registration should follow"),
};
leader_b.finish(None);
assert!(
tokio::time::timeout(Duration::from_millis(10), state_a.wait())
.await
.is_err(),
"completing another key must not wake this follower"
);
leader_a.finish(None);
tokio::time::timeout(Duration::from_millis(100), state_a.wait())
.await
.expect("completing the matching key must wake its follower")
.expect("successful completion should not publish an error");
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn candidate_selection_cache_follower_observes_completion_before_first_poll() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let mut leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let state = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
// Complete before wait() is constructed or polled. notify_waiters()
// alone would lose this notification and wait for the full timeout.
leader.finish(None);
tokio::time::timeout(Duration::from_millis(100), state.wait())
.await
.expect("completed follower must not miss the broadcast")
.expect("successful completion should not publish an error");
}
#[tokio::test]
async fn candidate_selection_cache_shares_leader_failure() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let mut leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let state = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
leader.finish(Some(DataLayerError::Sql(
"forced candidate cache load failure".to_string(),
)));
let error = tokio::time::timeout(Duration::from_millis(100), state.wait())
.await
.expect("failed load should release its follower")
.expect_err("follower should observe the leader failure");
assert_eq!(
error.to_string(),
"sql error: forced candidate cache load failure"
);
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn candidate_selection_cache_broadcasts_to_all_same_key_followers() {
const FOLLOWERS: usize = 64;
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let mut leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let mut tasks = Vec::with_capacity(FOLLOWERS);
for _ in 0..FOLLOWERS {
let state = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("same-key registration should follow"),
};
tasks.push(tokio::spawn(async move { state.wait().await }));
}
tokio::task::yield_now().await;
leader.finish(None);
for task in tasks {
tokio::time::timeout(Duration::from_millis(250), task)
.await
.expect("all same-key followers should receive completion")
.expect("follower task should finish")
.expect("successful completion should not publish an error");
}
}
#[tokio::test]
async fn candidate_selection_cache_cancelled_guard_wakes_registered_follower() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let guard = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let state = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
drop(guard);
tokio::time::timeout(Duration::from_millis(100), state.wait())
.await
.expect("leader cancellation must wake an existing follower")
.expect("leader cancellation should allow a retry");
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn candidate_selection_cache_clear_wakes_follower_and_old_guard_keeps_new_flight() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let old_guard = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_state = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
let old_epoch = old_guard.epoch();
cache.clear();
assert!(cache.epoch.load(Ordering::Acquire) > old_epoch);
let mut new_guard = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("registration after clear should lead"),
};
assert_ne!(old_guard.epoch(), new_guard.epoch());
// Dropping the invalidated leader's RAII guard must not remove the
// new flight created for the same key after clear().
drop(old_guard);
assert!(cache
.inflight
.lock()
.unwrap()
.get(&key)
.is_some_and(|current| Arc::ptr_eq(current, &new_guard.state)));
tokio::time::timeout(Duration::from_millis(100), old_state.wait())
.await
.expect("clear must wake followers of the invalidated flight")
.expect("clear should allow a retry");
new_guard.finish(None);
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn candidate_selection_cache_clear_invalidates_entries() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
@@ -587,6 +927,123 @@ mod tests {
assert_eq!(inner.calls(), 2);
}
#[test]
fn candidate_selection_cache_rejects_publication_from_pre_clear_flight() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let mut stale_leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
cache.clear();
stale_leader.finish_loaded(Vec::new());
assert!(cache
.entries
.get_fresh(&key, CANDIDATE_SELECTION_CACHE_TTL)
.is_none());
}
#[test]
fn candidate_selection_cache_clear_keeps_active_load_admission_bounded() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let mut cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
cache.admission = Arc::new(Semaphore::new(2));
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let mut detached_leaders = Vec::new();
for _ in 0..2 {
let leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("load below the hard active limit should lead"),
};
cache.clear();
detached_leaders.push(leader);
}
assert_eq!(cache.admission.available_permits(), 0);
assert!(matches!(
cache.register_inflight(&key),
InflightRegistration::Saturated
));
drop(detached_leaders);
assert_eq!(cache.admission.available_permits(), 2);
assert!(matches!(
cache.register_inflight(&key),
InflightRegistration::Leader(_)
));
}
#[test]
fn candidate_selection_cache_expired_leader_cannot_publish_over_replacement() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let mut old_leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_state = Arc::clone(&old_leader.state);
cache.expire_inflight(&key, &old_state);
let mut replacement = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("expiration should allow a replacement leader"),
};
assert_eq!(old_leader.epoch(), replacement.epoch());
old_leader.finish_loaded(vec![sample_row("stale-key", 1)]);
assert!(cache
.entries
.get_fresh(&key, CANDIDATE_SELECTION_CACHE_TTL)
.is_none());
replacement.finish_loaded(vec![sample_row("fresh-key", 2)]);
let cached = cache
.entries
.get_fresh(&key, CANDIDATE_SELECTION_CACHE_TTL)
.expect("replacement should publish");
assert_eq!(cached[0].key_id, "fresh-key");
}
#[tokio::test]
async fn candidate_selection_cache_rechecks_fresh_entry_after_leader_registration() {
let inner = Arc::new(StubCandidateSelectionRepository::new(Duration::ZERO));
let cache = CachedMinimalCandidateSelectionReadRepository::new(inner);
let key = CandidateSelectionCacheKey::ApiFormat {
api_format: "openai:chat".to_string(),
};
let expected = vec![sample_row("cached-key", 1)];
cache.entries.insert(
key.clone(),
expected.clone(),
CANDIDATE_SELECTION_CACHE_TTL,
CANDIDATE_SELECTION_CACHE_MAX_ENTRIES,
);
let loads = AtomicUsize::new(0);
// Exercise the post-initial-miss path directly to model a concurrent
// writer filling the cache immediately before flight registration.
let rows = cache
.load_after_cache_miss(key, || async {
loads.fetch_add(1, Ordering::SeqCst);
Ok(Vec::new())
})
.await
.expect("fresh entry should satisfy the lookup");
assert_eq!(rows, expected);
assert_eq!(loads.load(Ordering::SeqCst), 0);
assert!(cache.inflight.lock().unwrap().is_empty());
}
#[tokio::test]
async fn candidate_selection_cache_releases_inflight_when_leader_is_cancelled() {
let inner = Arc::new(FirstLoadPendingThenFastRepository::new());
+105 -3
View File
@@ -1,8 +1,10 @@
use super::{
ApiKeyLastUsedDelta, DataLayerError, GatewayDataState, GeminiFileMappingListQuery,
GeminiFileMappingStats, ProviderCatalogKeyListQuery, PublicHealthStatusCount,
PublicHealthTimelineBucket, StoredGeminiFileMapping, StoredGeminiFileMappingListPage,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
GeminiFileMappingStats, ProviderCatalogKeyAdaptiveStateUpdate,
ProviderCatalogKeyHealthStateUpdate, ProviderCatalogKeyListQuery,
ProviderCatalogKeyRuntimeMetadataUpdate, ProviderCatalogKeyStatusSnapshotUpdate,
PublicHealthStatusCount, PublicHealthTimelineBucket, StoredGeminiFileMapping,
StoredGeminiFileMappingListPage, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogKeyMaintenanceSummary, StoredProviderCatalogKeyPage,
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider, StoredRequestCandidate,
UpsertGeminiFileMappingRecord, UpsertRequestCandidateRecord,
@@ -370,6 +372,34 @@ impl GatewayDataState {
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_oauth_runtime_state(
&self,
key_id: &str,
oauth_invalid_at_unix_secs: Option<u64>,
oauth_invalid_reason: Option<&str>,
encrypted_auth_config_update: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, DataLayerError> {
let updated = match &self.provider_catalog_writer {
Some(repository) => {
repository
.update_key_oauth_runtime_state(
key_id,
oauth_invalid_at_unix_secs,
oauth_invalid_reason,
encrypted_auth_config_update,
updated_at_unix_secs,
)
.await
}
None => Ok(false),
}?;
if updated {
self.clear_provider_catalog_cache();
}
Ok(updated)
}
pub(crate) async fn create_provider_catalog_key(
&self,
key: &StoredProviderCatalogKey,
@@ -681,4 +711,76 @@ impl GatewayDataState {
}
Ok(updated)
}
pub(crate) async fn reset_provider_catalog_key_error_count(
&self,
key_id: &str,
) -> Result<bool, DataLayerError> {
let updated = match &self.provider_catalog_writer {
Some(repository) => repository.reset_key_error_count(key_id).await,
None => Ok(false),
}?;
if updated {
self.clear_provider_catalog_cache();
}
Ok(updated)
}
pub(crate) async fn compare_and_update_provider_catalog_key_adaptive_state(
&self,
update: &ProviderCatalogKeyAdaptiveStateUpdate,
) -> Result<bool, DataLayerError> {
let Some(repository) = &self.provider_catalog_writer else {
return Ok(false);
};
let updated = repository
.compare_and_update_key_adaptive_state(update)
.await?;
// A false CAS result normally means another instance won the write. Drop the
// five-second read cache before the caller reloads and retries.
self.clear_provider_catalog_cache();
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_runtime_metadata(
&self,
update: &ProviderCatalogKeyRuntimeMetadataUpdate,
) -> Result<bool, DataLayerError> {
let Some(repository) = &self.provider_catalog_writer else {
return Ok(false);
};
let updated = repository.update_key_runtime_metadata(update).await?;
// A false result is a namespace CAS conflict. Drop the read cache so
// the caller's retry observes the writer that won the race.
self.clear_provider_catalog_cache();
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_status_snapshot(
&self,
update: &ProviderCatalogKeyStatusSnapshotUpdate,
) -> Result<bool, DataLayerError> {
let Some(repository) = &self.provider_catalog_writer else {
return Ok(false);
};
let updated = repository.update_key_status_snapshot(update).await?;
if updated {
self.clear_provider_catalog_cache();
}
Ok(updated)
}
pub(crate) async fn compare_and_update_provider_catalog_key_health_state(
&self,
update: &ProviderCatalogKeyHealthStateUpdate,
) -> Result<bool, DataLayerError> {
let Some(repository) = &self.provider_catalog_writer else {
return Ok(false);
};
let updated = repository
.compare_and_update_key_health_state(update)
.await?;
self.clear_provider_catalog_cache();
Ok(updated)
}
}
+444 -125
View File
@@ -3,76 +3,248 @@ use aether_data_contracts::repository::candidate_selection::MinimalCandidateSele
use aether_data_contracts::repository::candidates::RequestCandidateReadRepository;
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
use aether_runtime_state::RuntimeQueueStore;
use std::collections::HashSet;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::Notify;
use std::time::Duration;
use super::{GatewayDataConfig, GatewayDataState, StoredSystemConfigEntry};
use super::{
GatewayDataConfig, GatewayDataState, StoredSystemConfigEntry, SystemConfigValueCacheState,
SystemConfigValueInflightCompletion, SystemConfigValueInflightState,
};
const SYSTEM_CONFIG_VALUE_CACHE_TTL: Duration = Duration::from_secs(30);
fn system_config_value_load_state() -> &'static SystemConfigValueLoadState {
static STATE: std::sync::OnceLock<SystemConfigValueLoadState> = std::sync::OnceLock::new();
STATE.get_or_init(SystemConfigValueLoadState::default)
}
#[derive(Debug, Default)]
struct SystemConfigValueLoadState {
inflight: std::sync::Mutex<HashSet<String>>,
notify: Notify,
}
const SYSTEM_CONFIG_VALUE_CACHE_MAX_ENTRIES: usize = 512;
const SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT: usize = 512;
enum SystemConfigValueLoadRegistration<'a> {
Leader(SystemConfigValueLoadGuard<'a>),
Follower,
Bypass,
Follower(Arc<SystemConfigValueInflightState>),
Saturated,
}
struct SystemConfigValueLoadGuard<'a> {
state: &'a SystemConfigValueLoadState,
cache: &'a SystemConfigValueCacheState,
key: Option<String>,
state: Arc<SystemConfigValueInflightState>,
admission: Option<tokio::sync::OwnedSemaphorePermit>,
}
impl SystemConfigValueInflightState {
async fn wait(&self) -> SystemConfigValueInflightCompletion {
loop {
if let Some(completion) = self.completion.get() {
return completion.clone();
}
let mut notified = Box::pin(self.notify.notified());
notified.as_mut().enable();
if let Some(completion) = self.completion.get() {
return completion.clone();
}
notified.await;
}
}
}
impl SystemConfigValueCacheState {
fn get(&self, key: &str) -> Option<Option<serde_json::Value>> {
self.entries.get_fresh(key, SYSTEM_CONFIG_VALUE_CACHE_TTL)
}
fn register(&self, key: &str) -> SystemConfigValueLoadRegistration<'_> {
{
let inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return SystemConfigValueLoadRegistration::Follower(Arc::clone(state));
}
}
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return SystemConfigValueLoadRegistration::Follower(Arc::clone(state));
}
if inflight.len() >= SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT {
return SystemConfigValueLoadRegistration::Saturated;
}
let Ok(admission) = Arc::clone(&self.admission).try_acquire_owned() else {
return SystemConfigValueLoadRegistration::Saturated;
};
let state = Arc::new(SystemConfigValueInflightState {
notify: Arc::new(tokio::sync::Notify::new()),
completion: std::sync::OnceLock::new(),
});
inflight.insert(key.to_string(), Arc::clone(&state));
SystemConfigValueLoadRegistration::Leader(SystemConfigValueLoadGuard {
cache: self,
key: Some(key.to_string()),
state,
admission: Some(admission),
})
}
fn finish_loaded(
&self,
key: &str,
state: &Arc<SystemConfigValueInflightState>,
admission: tokio::sync::OwnedSemaphorePermit,
value: Option<serde_json::Value>,
) {
self.finish_current(
key,
state,
admission,
SystemConfigValueInflightCompletion::Loaded,
Some(value),
);
}
fn finish_current(
&self,
key: &str,
state: &Arc<SystemConfigValueInflightState>,
admission: tokio::sync::OwnedSemaphorePermit,
completion: SystemConfigValueInflightCompletion,
cache_value: Option<Option<serde_json::Value>>,
) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
drop(admission);
debug_assert!(self.admission.available_permits() > 0);
if !inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
{
return;
}
if let Some(value) = cache_value {
self.entries.insert(
key.to_string(),
value,
SYSTEM_CONFIG_VALUE_CACHE_TTL,
SYSTEM_CONFIG_VALUE_CACHE_MAX_ENTRIES,
);
}
let completed = state.completion.set(completion).is_ok();
inflight.remove(key);
drop(inflight);
drop(_mutation);
if completed {
state.notify.notify_waiters();
}
}
fn invalidate(&self, key: &str) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.entries.remove(key);
let state = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(key);
let completed = state.as_ref().is_some_and(|state| {
state
.completion
.set(SystemConfigValueInflightCompletion::Invalidated)
.is_ok()
});
drop(_mutation);
if completed {
state
.expect("completed state should exist")
.notify
.notify_waiters();
}
}
fn clear(&self) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.entries.clear();
let states = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain()
.map(|(_, state)| state)
.collect::<Vec<_>>();
let completed = states
.iter()
.filter(|state| {
state
.completion
.set(SystemConfigValueInflightCompletion::Invalidated)
.is_ok()
})
.cloned()
.collect::<Vec<_>>();
drop(_mutation);
for state in completed {
state.notify.notify_waiters();
}
}
}
impl Default for SystemConfigValueCacheState {
fn default() -> Self {
Self {
entries: aether_cache::ExpiringMap::default(),
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
mutation: std::sync::Mutex::new(()),
admission: Arc::new(tokio::sync::Semaphore::new(
SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT,
)),
}
}
}
impl SystemConfigValueLoadGuard<'_> {
fn finish_loaded(&mut self, value: Option<serde_json::Value>) {
if let Some(key) = self.key.take() {
let admission = self
.admission
.take()
.expect("active system config leader must own admission");
self.cache
.finish_loaded(&key, &self.state, admission, value);
}
}
fn finish(&mut self, completion: SystemConfigValueInflightCompletion) {
if let Some(key) = self.key.take() {
let admission = self
.admission
.take()
.expect("active system config leader must own admission");
self.cache
.finish_current(&key, &self.state, admission, completion, None);
}
}
}
impl Drop for SystemConfigValueLoadGuard<'_> {
fn drop(&mut self) {
if let Some(key) = self.key.take() {
self.state.finish(&key);
}
}
}
impl SystemConfigValueLoadState {
fn register(&self, key: &str) -> SystemConfigValueLoadRegistration<'_> {
match self.inflight.lock() {
Ok(mut inflight) => {
if inflight.contains(key) {
SystemConfigValueLoadRegistration::Follower
} else {
inflight.insert(key.to_string());
SystemConfigValueLoadRegistration::Leader(SystemConfigValueLoadGuard {
state: self,
key: Some(key.to_string()),
})
}
}
Err(_) => SystemConfigValueLoadRegistration::Bypass,
}
}
fn notified(&self) -> tokio::sync::futures::Notified<'_> {
self.notify.notified()
}
fn finish(&self, key: &str) {
let removed = self
.inflight
.lock()
.map(|mut inflight| inflight.remove(key))
.unwrap_or(false);
if removed {
self.notify.notify_waiters();
}
self.finish(SystemConfigValueInflightCompletion::Cancelled);
}
}
@@ -83,6 +255,139 @@ fn current_system_config_updated_at_unix_secs() -> u64 {
.as_secs()
}
#[cfg(test)]
mod system_config_value_cache_tests {
use super::*;
fn leader<'a>(
cache: &'a SystemConfigValueCacheState,
key: &str,
) -> SystemConfigValueLoadGuard<'a> {
match cache.register(key) {
SystemConfigValueLoadRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
}
}
#[tokio::test]
async fn completion_before_first_poll_releases_system_config_follower() {
let cache = SystemConfigValueCacheState::default();
let mut leader = leader(&cache, "key-a");
let follower = match cache.register("key-a") {
SystemConfigValueLoadRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
leader.finish_loaded(Some(serde_json::json!({"version": 1})));
assert!(matches!(
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("completed follower must not miss the notification"),
SystemConfigValueInflightCompletion::Loaded
));
assert_eq!(
cache.get("key-a"),
Some(Some(serde_json::json!({"version": 1})))
);
}
#[tokio::test]
async fn system_config_follower_receives_leader_failure() {
let cache = SystemConfigValueCacheState::default();
let mut leader = leader(&cache, "key-a");
let follower = match cache.register("key-a") {
SystemConfigValueLoadRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
leader.finish(SystemConfigValueInflightCompletion::Failed(
DataLayerError::Sql("forced system config failure".to_string()),
));
let completion = tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("failed load should release its follower");
let SystemConfigValueInflightCompletion::Failed(error) = completion else {
panic!("follower should observe the leader failure");
};
assert_eq!(error.to_string(), "sql error: forced system config failure");
}
#[tokio::test]
async fn invalidation_wins_and_old_guard_preserves_replacement() {
let cache = SystemConfigValueCacheState::default();
let mut old_leader = leader(&cache, "key-a");
let old_follower = match cache.register("key-a") {
SystemConfigValueLoadRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
cache.invalidate("key-a");
let replacement = leader(&cache, "key-a");
old_leader.finish_loaded(Some(serde_json::json!({"stale": true})));
assert!(matches!(
old_follower.wait().await,
SystemConfigValueInflightCompletion::Invalidated
));
assert_eq!(cache.get("key-a"), None);
assert!(matches!(
cache.register("key-a"),
SystemConfigValueLoadRegistration::Follower(_)
));
drop(replacement);
}
#[test]
fn system_config_states_are_independent_and_inflight_is_bounded() {
let first = SystemConfigValueCacheState::default();
let second = SystemConfigValueCacheState::default();
let first_guard = leader(&first, "shared-key");
let second_guard = leader(&second, "shared-key");
drop(first_guard);
drop(second_guard);
let mut guards = Vec::with_capacity(SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT);
for index in 0..SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT {
let key = format!("bounded-{index}");
guards.push(leader(&first, &key));
}
assert!(matches!(
first.register("over-capacity"),
SystemConfigValueLoadRegistration::Saturated
));
drop(guards);
}
#[test]
fn capacity_full_cancelled_system_config_follower_can_retry() {
let cache = SystemConfigValueCacheState::default();
let mut active = Vec::with_capacity(SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT - 1);
for index in 0..SYSTEM_CONFIG_VALUE_CACHE_MAX_INFLIGHT - 1 {
active.push(leader(&cache, &format!("active-{index}")));
}
let current = leader(&cache, "retry-key");
let follower = match cache.register("retry-key") {
SystemConfigValueLoadRegistration::Follower(state) => state,
_ => panic!("same-key request should follow at full capacity"),
};
assert_eq!(cache.admission.available_permits(), 0);
assert!(matches!(
cache.register("over-capacity"),
SystemConfigValueLoadRegistration::Saturated
));
drop(current);
assert!(matches!(
follower.completion.get(),
Some(SystemConfigValueInflightCompletion::Cancelled)
));
let mut replacement = leader(&cache, "retry-key");
replacement.finish(SystemConfigValueInflightCompletion::Cancelled);
assert_eq!(cache.admission.available_permits(), 1);
drop(active);
}
}
impl GatewayDataState {
pub(crate) fn disabled() -> Self {
Self::default()
@@ -478,6 +783,12 @@ impl GatewayDataState {
self.wallet_reader.is_some()
}
#[cfg(test)]
pub(crate) fn without_wallet_reader_for_tests(mut self) -> Self {
self.wallet_reader = None;
self
}
pub(crate) fn has_wallet_writer(&self) -> bool {
self.wallet_writer.is_some()
}
@@ -502,82 +813,87 @@ 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);
}
if let Some(value) = self.system_config_value_cache.get(key) {
return Ok(value);
}
let load_state = system_config_value_load_state();
loop {
let notified = load_state.notified();
match load_state.register(key) {
SystemConfigValueLoadRegistration::Bypass => {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
let value = crate::request_diagnostics::observe_db_operation(
"system_config_value",
self.database_pool_summary(),
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()));
return Ok(value);
match self.system_config_value_cache.register(key) {
SystemConfigValueLoadRegistration::Saturated => {
return Err(DataLayerError::TimedOut(format!(
"system config cache admission saturated for key '{key}'"
)));
}
SystemConfigValueLoadRegistration::Follower => {
notified.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 {
SystemConfigValueLoadRegistration::Follower(state) => match state.wait().await {
SystemConfigValueInflightCompletion::Failed(error) => {
return Err(error);
}
SystemConfigValueInflightCompletion::Loaded => {
if let Some(value) = self.system_config_value_cache.get(key) {
return Ok(value);
}
}
}
SystemConfigValueLoadRegistration::Leader(_guard) => {
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 {
SystemConfigValueInflightCompletion::Cancelled
| SystemConfigValueInflightCompletion::Invalidated => {}
},
SystemConfigValueLoadRegistration::Leader(mut guard) => {
if let Some(value) = self.system_config_value_cache.get(key) {
guard.finish(SystemConfigValueInflightCompletion::Loaded);
return Ok(value);
}
match self.load_system_config_value_uncached(key).await {
Ok(value) => {
guard.finish_loaded(value.clone());
return Ok(value);
}
Err(error) => {
guard
.finish(SystemConfigValueInflightCompletion::Failed(error.clone()));
return Err(error);
}
}
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
let value = crate::request_diagnostics::observe_db_operation(
"system_config_value",
self.database_pool_summary(),
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()));
return Ok(value);
}
}
}
}
async fn load_system_config_value_uncached(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
crate::request_diagnostics::observe_db_operation(
"system_config_value",
self.database_pool_summary(),
backends.find_system_config_value(key),
)
.await
}
pub(crate) async fn find_system_config_value_strong(
&self,
key: &str,
) -> Result<Option<serde_json::Value>, DataLayerError> {
if let Some(values) = &self.system_config_values {
return Ok(values
.read()
.expect("system config values lock")
.get(key)
.map(|entry| entry.value.clone()));
}
let Some(backends) = self.backends.as_ref() else {
return Ok(None);
};
crate::request_diagnostics::observe_db_operation(
"system_config_value_strong",
self.database_pool_summary(),
backends.find_system_config_value(key),
)
.await
}
pub(crate) async fn upsert_system_config_value(
&self,
key: &str,
@@ -668,10 +984,7 @@ impl GatewayDataState {
}
fn clear_cached_system_config_value(&self, key: &str) {
self.system_config_value_cache
.write()
.expect("system config value cache lock")
.remove(key);
self.system_config_value_cache.invalidate(key);
}
pub(crate) async fn read_admin_system_stats(
@@ -687,24 +1000,30 @@ impl GatewayDataState {
&self,
target: aether_data::repository::system::AdminSystemPurgeTarget,
) -> Result<aether_data::repository::system::AdminSystemPurgeSummary, DataLayerError> {
if matches!(
target,
let purges_config = matches!(
&target,
aether_data::repository::system::AdminSystemPurgeTarget::Config
) {
);
if purges_config {
if let Some(values) = &self.system_config_values {
let mut values = values.write().expect("system config values lock");
let deleted = values.len() as u64;
values.clear();
self.system_config_value_cache.clear();
let mut summary =
aether_data::repository::system::AdminSystemPurgeSummary::default();
summary.add("system_configs", deleted);
return Ok(summary);
}
}
match self.backends.as_ref() {
let result = match self.backends.as_ref() {
Some(backends) => backends.purge_admin_system_data(target).await,
None => Ok(aether_data::repository::system::AdminSystemPurgeSummary::default()),
};
if purges_config && result.is_ok() {
self.system_config_value_cache.clear();
}
result
}
pub(crate) async fn export_admin_system_usage_aggregates(
@@ -13,7 +13,7 @@ use aether_data_contracts::repository::provider_catalog::{
};
use aether_data_contracts::repository::settlement::{StoredUsageSettlement, UsageSettlementInput};
use aether_data_contracts::repository::usage::{
ProxyNodeCounterDelta, StoredRequestUsageAudit, UpsertUsageRecord,
ProxyNodeCounterDelta, StoredRequestUsageAudit, UpsertUsageRecord, UsageWriteRepository,
};
use aether_data_contracts::repository::video_tasks::{StoredVideoTask, VideoTaskLookupKey};
use aether_runtime_state::RuntimeQueueStore;
@@ -252,6 +252,12 @@ impl UsageRuntimeAccess for GatewayDataState {
GatewayDataState::usage_worker_queue(self)
}
fn supports_first_byte_usage_fast_path(&self) -> bool {
self.usage_writer
.as_ref()
.is_some_and(|repository| repository.supports_first_byte_usage_fast_path())
}
fn usage_worker_should_defer_for_database_pressure(&self) -> bool {
self.database_pool_summary()
.as_ref()
@@ -323,12 +329,57 @@ impl aether_usage_runtime::ManualProxyNodeCounter for GatewayDataState {
#[async_trait]
impl UsageRecordWriter for GatewayDataState {
fn supports_first_byte_usage_batch(&self) -> bool {
self.usage_writer
.as_ref()
.is_some_and(|repository| repository.supports_first_byte_usage_batch())
}
fn first_byte_usage_writer_identity(&self) -> Option<usize> {
self.usage_writer
.as_ref()
.map(|repository| std::sync::Arc::as_ptr(repository) as *const () as usize)
}
fn supports_pending_usage_batch(&self) -> bool {
self.usage_writer
.as_ref()
.is_some_and(|repository| repository.supports_pending_usage_batch())
}
fn pending_usage_writer_identity(&self) -> Option<usize> {
self.usage_writer
.as_ref()
.map(|repository| std::sync::Arc::as_ptr(repository) as *const () as usize)
}
async fn upsert_usage_record(
&self,
record: UpsertUsageRecord,
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
GatewayDataState::upsert_usage(self, record).await
}
async fn upsert_first_byte_usage_record(
&self,
record: UpsertUsageRecord,
) -> Result<(), DataLayerError> {
GatewayDataState::upsert_first_byte_usage(self, record).await
}
async fn upsert_first_byte_usage_records(
&self,
records: Vec<UpsertUsageRecord>,
) -> Result<(), DataLayerError> {
GatewayDataState::upsert_first_byte_usage_many(self, records).await
}
async fn upsert_pending_usage_records(
&self,
records: Vec<UpsertUsageRecord>,
) -> Result<(), DataLayerError> {
GatewayDataState::upsert_pending_usage_many(self, records).await
}
}
#[cfg(test)]
+39 -10
View File
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::RwLock;
use std::time::Instant;
use super::auth::GatewayAuthApiKeySnapshot;
use super::candidates::{read_request_candidate_trace, RequestCandidateTrace};
@@ -13,6 +13,7 @@ use crate::provider_transport::{
read_provider_transport_snapshot, GatewayProviderTransportSnapshot,
};
use crate::video_tasks::LocalVideoTaskReadResponse;
use aether_cache::ExpiringMap;
use aether_data::repository::announcements::{
AnnouncementListQuery, AnnouncementReadRepository, AnnouncementWriteRepository,
CreateAnnouncementRecord, StoredAnnouncement, StoredAnnouncementPage, UpdateAnnouncementRecord,
@@ -120,8 +121,10 @@ use aether_data_contracts::repository::pool_scores::{
UpsertPoolMemberScore,
};
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyListQuery, ProviderCatalogReadRepository, ProviderCatalogWriteRepository,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
ProviderCatalogKeyAdaptiveStateUpdate, ProviderCatalogKeyHealthStateUpdate,
ProviderCatalogKeyListQuery, ProviderCatalogKeyRuntimeMetadataUpdate,
ProviderCatalogKeyStatusSnapshotUpdate, ProviderCatalogReadRepository,
ProviderCatalogWriteRepository, StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
StoredProviderCatalogKeyMaintenanceSummary, StoredProviderCatalogKeyPage,
StoredProviderCatalogKeyStats, StoredProviderCatalogProvider,
};
@@ -196,10 +199,30 @@ 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>)>>>,
system_config_value_cache: Arc<SystemConfigValueCacheState>,
billing_model_context_cache: Arc<BillingModelContextCacheState>,
}
pub(super) struct SystemConfigValueCacheState {
pub(super) entries: ExpiringMap<String, Option<serde_json::Value>>,
pub(super) inflight: std::sync::Mutex<HashMap<String, Arc<SystemConfigValueInflightState>>>,
pub(super) mutation: std::sync::Mutex<()>,
pub(super) admission: Arc<tokio::sync::Semaphore>,
}
pub(super) struct SystemConfigValueInflightState {
pub(super) notify: Arc<tokio::sync::Notify>,
pub(super) completion: OnceLock<SystemConfigValueInflightCompletion>,
}
#[derive(Clone)]
pub(super) enum SystemConfigValueInflightCompletion {
Loaded,
Failed(DataLayerError),
Cancelled,
Invalidated,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(super) enum BillingModelContextCacheKey {
ByModelId {
@@ -214,14 +237,20 @@ pub(super) enum BillingModelContextCacheKey {
},
}
#[derive(Default)]
pub(super) struct BillingModelContextCacheState {
pub(super) entries:
RwLock<HashMap<BillingModelContextCacheKey, (Instant, Option<StoredBillingModelContext>)>>,
pub(super) inflight: std::sync::Mutex<HashMap<BillingModelContextCacheKey, u64>>,
pub(super) inflight_notify: tokio::sync::Notify,
pub(super) next_inflight_token: std::sync::atomic::AtomicU64,
pub(super) entries: ExpiringMap<BillingModelContextCacheKey, Option<StoredBillingModelContext>>,
pub(super) inflight: std::sync::Mutex<
HashMap<BillingModelContextCacheKey, Arc<BillingModelContextInflightState>>,
>,
pub(super) epoch: std::sync::atomic::AtomicU64,
pub(super) mutation: std::sync::Mutex<()>,
pub(super) admission: Arc<tokio::sync::Semaphore>,
}
pub(super) struct BillingModelContextInflightState {
pub(super) epoch: u64,
pub(super) completion: std::sync::OnceLock<Result<(), DataLayerError>>,
pub(super) notify: tokio::sync::Notify,
}
impl fmt::Debug for GatewayDataState {
@@ -4,6 +4,7 @@ use super::{
PoolMemberHardState, PoolMemberIdentity, PoolMemberProbeAttempt, PoolMemberProbeResult,
PoolMemberScheduleFeedback, PoolScoreScope, StoredPoolMemberScore, UpsertPoolMemberScore,
};
use aether_data_contracts::repository::pool_scores::PoolMemberScoreUpsertMode;
impl GatewayDataState {
pub(crate) async fn list_ranked_pool_members(
@@ -56,6 +57,20 @@ impl GatewayDataState {
}
}
pub(crate) async fn upsert_pool_member_score_with_mode(
&self,
score: UpsertPoolMemberScore,
mode: PoolMemberScoreUpsertMode,
) -> Result<Option<StoredPoolMemberScore>, DataLayerError> {
match &self.pool_score_writer {
Some(repository) => repository
.upsert_pool_member_score_with_mode(score, mode)
.await
.map(Some),
None => Ok(None),
}
}
pub(crate) async fn record_pool_member_probe_result(
&self,
result: PoolMemberProbeResult,
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use aether_cache::ExpiringMap;
@@ -16,14 +16,16 @@ use tokio::sync::Notify;
const PROVIDER_CATALOG_CACHE_TTL: Duration = Duration::from_secs(5);
const PROVIDER_CATALOG_CACHE_MAX_ENTRIES: usize = 1024;
const PROVIDER_CATALOG_CACHE_MAX_INFLIGHT: usize = 1024;
const PROVIDER_CATALOG_CACHE_LOAD_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) struct CachedProviderCatalogReadRepository {
inner: Arc<dyn ProviderCatalogReadRepository>,
entries: ExpiringMap<ProviderCatalogCacheKey, ProviderCatalogCacheValue>,
inflight: Mutex<HashMap<ProviderCatalogCacheKey, u64>>,
inflight_notify: Notify,
next_inflight_token: AtomicU64,
inflight: Mutex<HashMap<ProviderCatalogCacheKey, Arc<ProviderCatalogInflightState>>>,
admission: Arc<tokio::sync::Semaphore>,
epoch: AtomicU64,
mutation: Mutex<()>,
}
impl CachedProviderCatalogReadRepository {
@@ -32,9 +34,11 @@ impl CachedProviderCatalogReadRepository {
inner,
entries: ExpiringMap::new(),
inflight: Mutex::new(HashMap::new()),
inflight_notify: Notify::new(),
next_inflight_token: AtomicU64::new(1),
admission: Arc::new(tokio::sync::Semaphore::new(
PROVIDER_CATALOG_CACHE_MAX_INFLIGHT,
)),
epoch: AtomicU64::new(0),
mutation: Mutex::new(()),
}
}
@@ -47,78 +51,181 @@ impl CachedProviderCatalogReadRepository {
F: Fn() -> Fut,
Fut: Future<Output = Result<ProviderCatalogCacheValue, DataLayerError>>,
{
if let Some(value) = self.entries.get_fresh(&key, PROVIDER_CATALOG_CACHE_TTL) {
return Ok(value);
}
loop {
let notified = self.inflight_notify.notified();
if let Some(value) = self.entries.get_fresh(&key, PROVIDER_CATALOG_CACHE_TTL) {
return Ok(value);
}
match self.register_inflight(&key) {
InflightRegistration::Bypass => return load().await,
InflightRegistration::Follower => {
notified.await;
InflightRegistration::Saturated => {
return Err(DataLayerError::TimedOut(format!(
"provider catalog cache admission saturated for {key:?}"
)));
}
InflightRegistration::Follower(state) => {
state.wait().await;
match self.follower_completion(&state) {
Some(ProviderCatalogInflightCompletion::Loaded(value)) => return Ok(value),
Some(ProviderCatalogInflightCompletion::Failed(error)) => {
return Err(error);
}
Some(
ProviderCatalogInflightCompletion::Cancelled
| ProviderCatalogInflightCompletion::Invalidated,
)
| None => continue,
}
}
InflightRegistration::Leader(mut guard) => {
if let Some(value) = self.entries.get_fresh(&key, PROVIDER_CATALOG_CACHE_TTL) {
guard.finish(ProviderCatalogInflightCompletion::Loaded(value.clone()));
return Ok(value);
}
}
InflightRegistration::Leader(token) => {
let mut guard = InflightGuard::new(self, key.clone(), token);
let load_epoch = self.epoch.load(Ordering::Acquire);
let result = load().await;
if let Ok(value) = &result {
if load_epoch == self.epoch.load(Ordering::Acquire) {
self.entries.insert(
key.clone(),
value.clone(),
PROVIDER_CATALOG_CACHE_TTL,
PROVIDER_CATALOG_CACHE_MAX_ENTRIES,
);
let result =
match tokio::time::timeout(PROVIDER_CATALOG_CACHE_LOAD_TIMEOUT, load())
.await
{
Ok(result) => result,
Err(_) => Err(DataLayerError::TimedOut(format!(
"provider catalog cache load exceeded {}ms for {key:?}",
PROVIDER_CATALOG_CACHE_LOAD_TIMEOUT.as_millis()
))),
};
match result {
Ok(value) => {
guard.finish_loaded(value.clone());
return Ok(value);
}
Err(error) => {
guard.finish(ProviderCatalogInflightCompletion::Failed(error.clone()));
return Err(error);
}
}
guard.finish();
return result;
}
}
}
}
fn register_inflight(&self, key: &ProviderCatalogCacheKey) -> InflightRegistration {
match self.inflight.lock() {
Ok(mut inflight) => {
if inflight.contains_key(key) {
return InflightRegistration::Follower;
}
let token = self.next_inflight_token.fetch_add(1, Ordering::AcqRel);
inflight.insert(key.clone(), token);
InflightRegistration::Leader(token)
fn register_inflight(&self, key: &ProviderCatalogCacheKey) -> InflightRegistration<'_> {
{
let inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return InflightRegistration::Follower(Arc::clone(state));
}
Err(_) => InflightRegistration::Bypass,
}
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return InflightRegistration::Follower(Arc::clone(state));
}
if inflight.len() >= PROVIDER_CATALOG_CACHE_MAX_INFLIGHT {
return InflightRegistration::Saturated;
}
let Ok(admission) = Arc::clone(&self.admission).try_acquire_owned() else {
return InflightRegistration::Saturated;
};
let state = Arc::new(ProviderCatalogInflightState {
notify: Arc::new(Notify::new()),
completion: OnceLock::new(),
epoch: self.epoch.load(Ordering::Acquire),
});
inflight.insert(key.clone(), Arc::clone(&state));
InflightRegistration::Leader(InflightGuard {
cache: self,
key: Some(key.clone()),
state,
admission: Some(admission),
})
}
fn finish_inflight(&self, key: &ProviderCatalogCacheKey, token: u64) {
let mut removed = false;
if let Ok(mut inflight) = self.inflight.lock() {
if inflight.get(key).copied() == Some(token) {
fn finish_inflight(
&self,
key: &ProviderCatalogCacheKey,
state: &Arc<ProviderCatalogInflightState>,
admission: tokio::sync::OwnedSemaphorePermit,
completion: ProviderCatalogInflightCompletion,
cache_value: Option<ProviderCatalogCacheValue>,
) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let removed = {
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
drop(admission);
debug_assert!(self.admission.available_permits() > 0);
if inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
&& self.epoch.load(Ordering::Acquire) == state.epoch
{
if let Some(value) = cache_value {
self.entries.insert(
key.clone(),
value,
PROVIDER_CATALOG_CACHE_TTL,
PROVIDER_CATALOG_CACHE_MAX_ENTRIES,
);
}
state.complete(completion);
inflight.remove(key);
removed = true;
true
} else {
false
}
}
};
drop(_mutation);
if removed {
self.inflight_notify.notify_waiters();
state.notify.notify_waiters();
}
}
fn follower_completion(
&self,
state: &ProviderCatalogInflightState,
) -> Option<ProviderCatalogInflightCompletion> {
let before = self.epoch.load(Ordering::Acquire);
let completion = state.completion.get().cloned();
let after = self.epoch.load(Ordering::Acquire);
(before == state.epoch && before == after)
.then_some(completion)
.flatten()
}
fn clear(&self) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.epoch.fetch_add(1, Ordering::AcqRel);
self.entries.clear();
let mut cleared_inflight = false;
if let Ok(mut inflight) = self.inflight.lock() {
cleared_inflight = !inflight.is_empty();
inflight.clear();
let states = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain()
.map(|(_, state)| state)
.collect::<Vec<_>>();
for state in &states {
state.complete(ProviderCatalogInflightCompletion::Invalidated);
}
if cleared_inflight {
self.inflight_notify.notify_waiters();
drop(_mutation);
for state in states {
state.notify.notify_waiters();
}
}
}
@@ -335,41 +442,83 @@ enum ProviderCatalogCacheValue {
KeyStats(Vec<StoredProviderCatalogKeyStats>),
}
enum InflightRegistration {
Leader(u64),
Follower,
Bypass,
struct ProviderCatalogInflightState {
notify: Arc<Notify>,
completion: OnceLock<ProviderCatalogInflightCompletion>,
epoch: u64,
}
impl ProviderCatalogInflightState {
fn complete(&self, completion: ProviderCatalogInflightCompletion) {
let _ = self.completion.set(completion);
}
async fn wait(&self) {
loop {
if self.completion.get().is_some() {
return;
}
let mut notified = Box::pin(self.notify.notified());
notified.as_mut().enable();
if self.completion.get().is_some() {
return;
}
notified.await;
}
}
}
#[derive(Clone)]
enum ProviderCatalogInflightCompletion {
Loaded(ProviderCatalogCacheValue),
Failed(DataLayerError),
Cancelled,
Invalidated,
}
enum InflightRegistration<'a> {
Leader(InflightGuard<'a>),
Follower(Arc<ProviderCatalogInflightState>),
Saturated,
}
struct InflightGuard<'a> {
cache: &'a CachedProviderCatalogReadRepository,
key: Option<ProviderCatalogCacheKey>,
token: u64,
state: Arc<ProviderCatalogInflightState>,
admission: Option<tokio::sync::OwnedSemaphorePermit>,
}
impl<'a> InflightGuard<'a> {
fn new(
cache: &'a CachedProviderCatalogReadRepository,
key: ProviderCatalogCacheKey,
token: u64,
) -> Self {
Self {
cache,
key: Some(key),
token,
}
impl InflightGuard<'_> {
fn finish_loaded(&mut self, value: ProviderCatalogCacheValue) {
let completion = ProviderCatalogInflightCompletion::Loaded(value.clone());
self.finish_with_cache(completion, Some(value));
}
fn finish(&mut self) {
fn finish(&mut self, completion: ProviderCatalogInflightCompletion) {
self.finish_with_cache(completion, None);
}
fn finish_with_cache(
&mut self,
completion: ProviderCatalogInflightCompletion,
cache_value: Option<ProviderCatalogCacheValue>,
) {
if let Some(key) = self.key.take() {
self.cache.finish_inflight(&key, self.token);
let admission = self
.admission
.take()
.expect("active provider catalog leader must own admission");
self.cache
.finish_inflight(&key, &self.state, admission, completion, cache_value);
}
}
}
impl Drop for InflightGuard<'_> {
fn drop(&mut self) {
self.finish();
self.finish(ProviderCatalogInflightCompletion::Cancelled);
}
}
@@ -384,3 +533,173 @@ fn normalize_ids(ids: &[String]) -> Vec<String> {
normalized.dedup();
normalized
}
#[cfg(test)]
mod tests {
use super::*;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
fn cache() -> CachedProviderCatalogReadRepository {
CachedProviderCatalogReadRepository::new(Arc::new(
InMemoryProviderCatalogReadRepository::seed(Vec::new(), Vec::new(), Vec::new()),
))
}
fn provider(id: &str) -> StoredProviderCatalogProvider {
StoredProviderCatalogProvider::new(
id.to_string(),
id.to_string(),
None,
"openai".to_string(),
)
.expect("provider should be valid")
}
#[tokio::test]
async fn provider_catalog_follower_observes_completion_before_first_poll() {
let cache = cache();
let key = ProviderCatalogCacheKey::Providers { active_only: false };
let mut leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
leader.finish(ProviderCatalogInflightCompletion::Loaded(
ProviderCatalogCacheValue::Providers(Vec::new()),
));
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("completion before the first poll must release the follower");
assert!(matches!(
cache.follower_completion(&follower),
Some(ProviderCatalogInflightCompletion::Loaded(_))
));
}
#[tokio::test]
async fn provider_catalog_follower_receives_leader_failure() {
let cache = cache();
let key = ProviderCatalogCacheKey::Providers { active_only: false };
let mut leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
leader.finish(ProviderCatalogInflightCompletion::Failed(
DataLayerError::Sql("forced provider catalog failure".to_string()),
));
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("failed load must release the follower");
let Some(ProviderCatalogInflightCompletion::Failed(error)) =
cache.follower_completion(&follower)
else {
panic!("follower should observe the failed completion");
};
assert_eq!(
error.to_string(),
"sql error: forced provider catalog failure"
);
}
#[test]
fn provider_catalog_old_guard_cannot_remove_replacement_after_clear() {
let cache = cache();
let key = ProviderCatalogCacheKey::Providers { active_only: false };
let old_leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
cache.clear();
let replacement = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("clear should admit a replacement"),
};
drop(old_leader);
assert!(matches!(
cache.register_inflight(&key),
InflightRegistration::Follower(_)
));
drop(replacement);
}
#[test]
fn provider_catalog_old_flight_after_clear_cannot_overwrite_replacement() {
let cache = cache();
let key = ProviderCatalogCacheKey::Providers { active_only: false };
let mut old_leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
cache.clear();
let mut replacement = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("clear should admit a replacement"),
};
replacement.finish_loaded(ProviderCatalogCacheValue::Providers(vec![provider(
"fresh",
)]));
old_leader.finish_loaded(ProviderCatalogCacheValue::Providers(vec![provider(
"stale",
)]));
let Some(ProviderCatalogCacheValue::Providers(cached)) =
cache.entries.get_fresh(&key, PROVIDER_CATALOG_CACHE_TTL)
else {
panic!("replacement value should remain cached");
};
assert_eq!(cached[0].id, "fresh");
}
#[test]
fn provider_catalog_capacity_full_cancelled_follower_can_retry_after_repeated_clear() {
let cache = cache();
let key = ProviderCatalogCacheKey::Providers { active_only: false };
let mut active = Vec::with_capacity(PROVIDER_CATALOG_CACHE_MAX_INFLIGHT);
for _ in 0..PROVIDER_CATALOG_CACHE_MAX_INFLIGHT - 1 {
let leader = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("each available permit should admit one leader"),
};
active.push(leader);
cache.clear();
}
let current = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("the final available permit should admit a leader"),
};
let follower = match cache.register_inflight(&key) {
InflightRegistration::Follower(state) => state,
_ => panic!("the same-key request should follow at full capacity"),
};
assert_eq!(cache.admission.available_permits(), 0);
assert!(matches!(
cache.register_inflight(&ProviderCatalogCacheKey::Providers { active_only: true }),
InflightRegistration::Saturated
));
drop(current);
assert!(matches!(
cache.follower_completion(&follower),
Some(ProviderCatalogInflightCompletion::Cancelled)
));
let mut replacement = match cache.register_inflight(&key) {
InflightRegistration::Leader(guard) => guard,
_ => panic!("cancelled follower retry should use the released permit"),
};
replacement.finish(ProviderCatalogInflightCompletion::Cancelled);
assert_eq!(cache.admission.available_permits(), 1);
}
}
@@ -1,4 +1,7 @@
use std::sync::Arc;
use std::collections::HashMap;
use std::future::Future;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use aether_cache::ExpiringMap;
@@ -9,16 +12,22 @@ use aether_data_contracts::repository::routing_profiles::{
StoredRoutingGroupVersion,
};
use async_trait::async_trait;
use dashmap::DashMap;
use tokio::sync::Notify;
// Routing profile writes clear this local cache. Keep the fallback TTL bounded
// so a missed cross-node invalidation cannot route traffic stale for minutes.
const ROUTING_GROUP_CACHE_STALE_TTL: Duration = Duration::from_secs(60);
const ROUTING_GROUP_CACHE_MAX_ENTRIES: usize = 4_096;
const ROUTING_GROUP_CACHE_MAX_LOAD_GUARDS: usize = 4_096;
const ROUTING_GROUP_CACHE_MAX_INFLIGHT: usize = 4_096;
const ROUTING_GROUP_CACHE_LOAD_TIMEOUT: Duration = Duration::from_secs(10);
pub(super) struct CachedRoutingGroupReadRepository {
inner: Arc<dyn RoutingGroupReadRepository>,
entries: ExpiringMap<RoutingGroupCacheKey, RoutingGroupCacheValue>,
load_guards: DashMap<RoutingGroupCacheKey, Arc<tokio::sync::Mutex<()>>>,
inflight: Mutex<HashMap<RoutingGroupCacheKey, Arc<RoutingGroupInflightState>>>,
admission: Arc<tokio::sync::Semaphore>,
generation: AtomicU64,
mutation: Mutex<()>,
}
impl CachedRoutingGroupReadRepository {
@@ -26,58 +35,245 @@ impl CachedRoutingGroupReadRepository {
Self {
inner,
entries: ExpiringMap::new(),
load_guards: DashMap::new(),
inflight: Mutex::new(HashMap::new()),
admission: Arc::new(tokio::sync::Semaphore::new(
ROUTING_GROUP_CACHE_MAX_INFLIGHT,
)),
generation: AtomicU64::new(0),
mutation: Mutex::new(()),
}
}
fn clear(&self) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.generation.fetch_add(1, Ordering::AcqRel);
self.entries.clear();
self.load_guards.clear();
let states = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain()
.map(|(_, state)| {
let _ = state
.completion
.set(RoutingGroupInflightCompletion::Invalidated);
state
})
.collect::<Vec<_>>();
drop(_mutation);
for state in states {
state.notify.notify_waiters();
}
}
async fn get_or_load(
async fn get_or_load<F, Fut>(
&self,
key: RoutingGroupCacheKey,
load: impl std::future::Future<Output = Result<RoutingGroupCacheValue, DataLayerError>>,
) -> Result<RoutingGroupCacheValue, DataLayerError> {
if let Some((value, _age)) = self
.entries
.get_with_age(&key, ROUTING_GROUP_CACHE_STALE_TTL)
{
return Ok(value);
mut load: F,
) -> Result<RoutingGroupCacheValue, DataLayerError>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<RoutingGroupCacheValue, DataLayerError>>,
{
loop {
if let Some(value) = self.cached_value(&key) {
return Ok(value);
}
match self.register_inflight(&key) {
RoutingGroupInflightRegistration::Saturated => {
return Err(DataLayerError::TimedOut(format!(
"routing group cache admission saturated for {key:?}"
)));
}
RoutingGroupInflightRegistration::Leader(mut guard) => {
// A previous leader can publish between the optimistic
// cache read and this registration.
if let Some(value) = self.cached_value(&key) {
guard.finish(RoutingGroupInflightCompletion::Loaded(value.clone()));
return Ok(value);
}
let result = match tokio::time::timeout(
ROUTING_GROUP_CACHE_LOAD_TIMEOUT,
load(),
)
.await
{
Ok(result) => result,
Err(_) => Err(DataLayerError::TimedOut(format!(
"routing group cache load exceeded {}ms for {key:?}",
ROUTING_GROUP_CACHE_LOAD_TIMEOUT.as_millis()
))),
};
match result {
Ok(value) => {
self.insert_if_generation(key.clone(), value.clone(), guard.generation);
guard.finish(RoutingGroupInflightCompletion::Loaded(value.clone()));
return Ok(value);
}
Err(error) => {
guard.finish(RoutingGroupInflightCompletion::Failed(
SharedDataLayerError::from(&error),
));
return Err(error);
}
}
}
RoutingGroupInflightRegistration::Follower(state) => {
state.wait().await;
match self.follower_completion(&state) {
Some(RoutingGroupInflightCompletion::Loaded(value)) => return Ok(value),
Some(RoutingGroupInflightCompletion::Failed(error)) => {
return Err(error.into_data_layer_error());
}
Some(
RoutingGroupInflightCompletion::Cancelled
| RoutingGroupInflightCompletion::Invalidated,
)
| None => continue,
}
}
}
}
let load_guard = self.load_guard_for(&key);
let _guard = load_guard.lock().await;
if let Some((value, _age)) = self
.entries
.get_with_age(&key, ROUTING_GROUP_CACHE_STALE_TTL)
{
return Ok(value);
}
fn cached_value(&self, key: &RoutingGroupCacheKey) -> Option<RoutingGroupCacheValue> {
self.entries
.get_with_age(key, ROUTING_GROUP_CACHE_STALE_TTL)
.map(|(value, _age)| value)
}
fn follower_completion(
&self,
state: &RoutingGroupInflightState,
) -> Option<RoutingGroupInflightCompletion> {
// Double-check the generation around the completion read. This keeps
// the hot follower path lock-free while ensuring clear() cannot race
// between an old-generation check and returning a loaded value.
let before = self.generation.load(Ordering::Acquire);
let completion = state.completion.get().cloned();
let after = self.generation.load(Ordering::Acquire);
(before == state.generation && before == after)
.then_some(completion)
.flatten()
}
fn insert_if_generation(
&self,
key: RoutingGroupCacheKey,
value: RoutingGroupCacheValue,
generation: u64,
) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if self.generation.load(Ordering::Acquire) != generation {
return;
}
let value = load.await?;
self.entries.insert(
key,
value.clone(),
value,
ROUTING_GROUP_CACHE_STALE_TTL,
ROUTING_GROUP_CACHE_MAX_ENTRIES,
);
Ok(value)
}
fn load_guard_for(&self, key: &RoutingGroupCacheKey) -> Arc<tokio::sync::Mutex<()>> {
if self.load_guards.len() > ROUTING_GROUP_CACHE_MAX_LOAD_GUARDS {
self.load_guards.clear();
fn register_inflight(
&self,
key: &RoutingGroupCacheKey,
) -> RoutingGroupInflightRegistration<'_> {
// Existing followers only touch the per-key map. Avoid taking the
// mutation lock on the 20k-request hot path; that lock is reserved
// for leader insertion and invalidation ordering.
{
let inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return RoutingGroupInflightRegistration::Follower(Arc::clone(state));
}
}
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(state) = inflight.get(key) {
return RoutingGroupInflightRegistration::Follower(Arc::clone(state));
}
if inflight.len() >= ROUTING_GROUP_CACHE_MAX_INFLIGHT {
return RoutingGroupInflightRegistration::Saturated;
}
let Ok(admission) = Arc::clone(&self.admission).try_acquire_owned() else {
return RoutingGroupInflightRegistration::Saturated;
};
let state = Arc::new(RoutingGroupInflightState {
notify: Arc::new(Notify::new()),
completion: OnceLock::new(),
generation: self.generation.load(Ordering::Acquire),
});
let generation = state.generation;
inflight.insert(key.clone(), Arc::clone(&state));
RoutingGroupInflightRegistration::Leader(RoutingGroupInflightGuard {
cache: self,
key: Some(key.clone()),
state,
generation,
admission: Some(admission),
})
}
fn finish_inflight(
&self,
key: &RoutingGroupCacheKey,
state: &Arc<RoutingGroupInflightState>,
admission: tokio::sync::OwnedSemaphorePermit,
completion: RoutingGroupInflightCompletion,
) {
let _mutation = self
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let removed = {
let mut inflight = self
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
drop(admission);
debug_assert!(self.admission.available_permits() > 0);
if inflight
.get(key)
.is_some_and(|current| Arc::ptr_eq(current, state))
{
let _ = state.completion.set(completion);
inflight.remove(key);
true
} else {
false
}
};
drop(_mutation);
if removed {
state.notify.notify_waiters();
}
self.load_guards
.entry(key.clone())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum RoutingGroupCacheKey {
ListGroups,
HasAnyBinding,
FindById(String),
FindByName(String),
FindSystemDefault,
@@ -92,11 +288,120 @@ enum RoutingGroupCacheKey {
#[derive(Debug, Clone)]
enum RoutingGroupCacheValue {
Groups(Vec<StoredRoutingGroup>),
Bool(bool),
Group(Option<StoredRoutingGroup>),
Bindings(Vec<StoredRoutingGroupBinding>),
Versions(Vec<StoredRoutingGroupVersion>),
}
struct RoutingGroupInflightState {
notify: Arc<Notify>,
completion: OnceLock<RoutingGroupInflightCompletion>,
generation: u64,
}
impl RoutingGroupInflightState {
async fn wait(&self) {
loop {
if self.completion.get().is_some() {
return;
}
// Register before checking completion a second time. This closes
// the completion/notify race even if the follower is not polled
// until after the leader has broadcast with notify_waiters().
let mut notified = Box::pin(self.notify.notified());
notified.as_mut().enable();
if self.completion.get().is_some() {
return;
}
notified.await;
}
}
}
#[derive(Clone)]
enum RoutingGroupInflightCompletion {
Loaded(RoutingGroupCacheValue),
Failed(SharedDataLayerError),
Cancelled,
Invalidated,
}
enum RoutingGroupInflightRegistration<'a> {
Leader(RoutingGroupInflightGuard<'a>),
Follower(Arc<RoutingGroupInflightState>),
Saturated,
}
struct RoutingGroupInflightGuard<'a> {
cache: &'a CachedRoutingGroupReadRepository,
key: Option<RoutingGroupCacheKey>,
state: Arc<RoutingGroupInflightState>,
generation: u64,
admission: Option<tokio::sync::OwnedSemaphorePermit>,
}
impl RoutingGroupInflightGuard<'_> {
fn finish(&mut self, completion: RoutingGroupInflightCompletion) {
if let Some(key) = self.key.take() {
let admission = self
.admission
.take()
.expect("active routing group leader must own admission");
self.cache
.finish_inflight(&key, &self.state, admission, completion);
}
}
}
impl Drop for RoutingGroupInflightGuard<'_> {
fn drop(&mut self) {
self.finish(RoutingGroupInflightCompletion::Cancelled);
}
}
#[derive(Clone)]
enum SharedDataLayerError {
InvalidConfiguration(String),
InvalidInput(String),
Postgres(String),
Redis(String),
Sql(String),
TimedOut(String),
UnexpectedValue(String),
}
impl From<&DataLayerError> for SharedDataLayerError {
fn from(error: &DataLayerError) -> Self {
match error {
DataLayerError::InvalidConfiguration(message) => {
Self::InvalidConfiguration(message.clone())
}
DataLayerError::InvalidInput(message) => Self::InvalidInput(message.clone()),
DataLayerError::Postgres(message) => Self::Postgres(message.clone()),
DataLayerError::Redis(message) => Self::Redis(message.clone()),
DataLayerError::Sql(message) => Self::Sql(message.clone()),
DataLayerError::TimedOut(message) => Self::TimedOut(message.clone()),
DataLayerError::UnexpectedValue(message) => Self::UnexpectedValue(message.clone()),
}
}
}
impl SharedDataLayerError {
fn into_data_layer_error(self) -> DataLayerError {
match self {
Self::InvalidConfiguration(message) => DataLayerError::InvalidConfiguration(message),
Self::InvalidInput(message) => DataLayerError::InvalidInput(message),
Self::Postgres(message) => DataLayerError::Postgres(message),
Self::Redis(message) => DataLayerError::Redis(message),
Self::Sql(message) => DataLayerError::Sql(message),
Self::TimedOut(message) => DataLayerError::TimedOut(message),
Self::UnexpectedValue(message) => DataLayerError::UnexpectedValue(message),
}
}
}
fn lookup_cache_key(lookup: &RoutingGroupLookupKey<'_>) -> RoutingGroupCacheKey {
match lookup {
RoutingGroupLookupKey::Id(id) => RoutingGroupCacheKey::FindById((*id).to_string()),
@@ -122,7 +427,7 @@ impl RoutingGroupReadRepository for CachedRoutingGroupReadRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
match self
.get_or_load(RoutingGroupCacheKey::ListGroups, async {
.get_or_load(RoutingGroupCacheKey::ListGroups, || async {
self.inner
.list_routing_groups()
.await
@@ -140,12 +445,16 @@ impl RoutingGroupReadRepository for CachedRoutingGroupReadRepository {
lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
let key = lookup_cache_key(&lookup);
let lookup_for_load = lookup.clone();
match self
.get_or_load(key, async {
self.inner
.find_routing_group(lookup)
.await
.map(RoutingGroupCacheValue::Group)
.get_or_load(key, move || {
let lookup = lookup_for_load.clone();
async move {
self.inner
.find_routing_group(lookup)
.await
.map(RoutingGroupCacheValue::Group)
}
})
.await?
{
@@ -164,7 +473,7 @@ impl RoutingGroupReadRepository for CachedRoutingGroupReadRepository {
subject_id: query.subject_id.clone(),
};
match self
.get_or_load(key, async {
.get_or_load(key, || async {
self.inner
.list_routing_group_bindings(query)
.await
@@ -177,13 +486,28 @@ impl RoutingGroupReadRepository for CachedRoutingGroupReadRepository {
}
}
async fn has_any_routing_group_binding(&self) -> Result<bool, DataLayerError> {
match self
.get_or_load(RoutingGroupCacheKey::HasAnyBinding, || async {
self.inner
.has_any_routing_group_binding()
.await
.map(RoutingGroupCacheValue::Bool)
})
.await?
{
RoutingGroupCacheValue::Bool(value) => Ok(value),
_ => Ok(false),
}
}
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 {
.get_or_load(key, || async {
self.inner
.list_routing_group_versions(group_id)
.await
@@ -201,11 +525,14 @@ impl RoutingGroupReadRepository for CachedRoutingGroupReadRepository {
mod tests {
use std::sync::atomic::{AtomicUsize, Ordering};
use tokio::sync::{Barrier, Semaphore};
use super::*;
#[derive(Default)]
struct CountingRoutingGroupReadRepository {
list_calls: AtomicUsize,
has_any_binding_calls: AtomicUsize,
}
#[async_trait]
@@ -229,6 +556,11 @@ mod tests {
Ok(Vec::new())
}
async fn has_any_routing_group_binding(&self) -> Result<bool, DataLayerError> {
self.has_any_binding_calls.fetch_add(1, Ordering::AcqRel);
Ok(false)
}
async fn list_routing_group_versions(
&self,
_group_id: &str,
@@ -237,6 +569,131 @@ mod tests {
}
}
#[derive(Clone, Copy)]
enum ControlledHasAnyBindingBehavior {
WaitThenSuccess(bool),
WaitThenError,
FirstWaitFalseThenTrue,
FirstPendingThenTrue,
}
struct ControlledRoutingGroupReadRepository {
behavior: ControlledHasAnyBindingBehavior,
has_any_binding_calls: AtomicUsize,
started: Semaphore,
release: Semaphore,
}
impl ControlledRoutingGroupReadRepository {
fn new(behavior: ControlledHasAnyBindingBehavior) -> Self {
Self {
behavior,
has_any_binding_calls: AtomicUsize::new(0),
started: Semaphore::new(0),
release: Semaphore::new(0),
}
}
fn calls(&self) -> usize {
self.has_any_binding_calls.load(Ordering::Acquire)
}
async fn wait_until_started(&self) {
self.started
.acquire()
.await
.expect("started semaphore should remain open")
.forget();
}
async fn wait_for_release(&self) {
self.release
.acquire()
.await
.expect("release semaphore should remain open")
.forget();
}
}
#[async_trait]
impl RoutingGroupReadRepository for ControlledRoutingGroupReadRepository {
async fn list_routing_groups(&self) -> Result<Vec<StoredRoutingGroup>, DataLayerError> {
Ok(Vec::new())
}
async fn find_routing_group(
&self,
_lookup: RoutingGroupLookupKey<'_>,
) -> Result<Option<StoredRoutingGroup>, DataLayerError> {
Ok(None)
}
async fn list_routing_group_bindings(
&self,
_query: &RoutingGroupBindingQuery,
) -> Result<Vec<StoredRoutingGroupBinding>, DataLayerError> {
Ok(Vec::new())
}
async fn has_any_routing_group_binding(&self) -> Result<bool, DataLayerError> {
let call = self.has_any_binding_calls.fetch_add(1, Ordering::AcqRel) + 1;
self.started.add_permits(1);
match self.behavior {
ControlledHasAnyBindingBehavior::WaitThenSuccess(value) => {
self.wait_for_release().await;
Ok(value)
}
ControlledHasAnyBindingBehavior::WaitThenError => {
self.wait_for_release().await;
Err(DataLayerError::TimedOut(
"shared routing load error".to_string(),
))
}
ControlledHasAnyBindingBehavior::FirstWaitFalseThenTrue if call == 1 => {
self.wait_for_release().await;
Ok(false)
}
ControlledHasAnyBindingBehavior::FirstPendingThenTrue if call == 1 => {
std::future::pending().await
}
ControlledHasAnyBindingBehavior::FirstWaitFalseThenTrue
| ControlledHasAnyBindingBehavior::FirstPendingThenTrue => Ok(true),
}
}
async fn list_routing_group_versions(
&self,
_group_id: &str,
) -> Result<Vec<StoredRoutingGroupVersion>, DataLayerError> {
Ok(Vec::new())
}
}
async fn wait_for_same_key_inflight_participants(
repository: &CachedRoutingGroupReadRepository,
participants: usize,
) {
tokio::time::timeout(Duration::from_secs(1), async {
loop {
let participant_count = repository
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.get(&RoutingGroupCacheKey::HasAnyBinding)
.map(Arc::strong_count)
.unwrap_or_default();
// One Arc is retained by the map and every active request
// owns one through its leader guard or follower state.
if participant_count >= participants + 1 {
break;
}
tokio::task::yield_now().await;
}
})
.await
.expect("all same-key requests should join one inflight load");
}
#[tokio::test]
async fn clear_local_cache_forces_next_load() {
let inner = Arc::new(CountingRoutingGroupReadRepository::default());
@@ -252,11 +709,236 @@ mod tests {
.expect("cached list should load");
assert_eq!(inner.list_calls.load(Ordering::Acquire), 1);
assert!(!repository
.has_any_routing_group_binding()
.await
.expect("initial binding existence should load"));
assert!(!repository
.has_any_routing_group_binding()
.await
.expect("cached binding existence should load"));
assert_eq!(inner.has_any_binding_calls.load(Ordering::Acquire), 1);
repository.clear_local_cache();
repository
.list_routing_groups()
.await
.expect("cleared list should reload");
assert_eq!(inner.list_calls.load(Ordering::Acquire), 2);
assert!(!repository
.has_any_routing_group_binding()
.await
.expect("cleared binding existence should reload"));
assert_eq!(inner.has_any_binding_calls.load(Ordering::Acquire), 2);
}
#[tokio::test]
async fn follower_does_not_miss_completion_before_first_poll() {
let inner = Arc::new(CountingRoutingGroupReadRepository::default());
let repository = CachedRoutingGroupReadRepository::new(inner);
let key = RoutingGroupCacheKey::HasAnyBinding;
let mut leader = match repository.register_inflight(&key) {
RoutingGroupInflightRegistration::Leader(leader) => leader,
_ => panic!("first registration should lead"),
};
let follower = match repository.register_inflight(&key) {
RoutingGroupInflightRegistration::Follower(state) => state,
_ => panic!("second registration should follow"),
};
// Complete before the follower wait future is created or polled. A
// bare notify_waiters().await sequence would sleep forever here.
leader.finish(RoutingGroupInflightCompletion::Loaded(
RoutingGroupCacheValue::Bool(true),
));
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("completed follower should not miss the broadcast");
assert!(matches!(
repository.follower_completion(&follower),
Some(RoutingGroupInflightCompletion::Loaded(
RoutingGroupCacheValue::Bool(true)
))
));
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_same_key_requests_share_one_completed_load() {
const TASKS: usize = 64;
let inner = Arc::new(ControlledRoutingGroupReadRepository::new(
ControlledHasAnyBindingBehavior::WaitThenSuccess(true),
));
let repository = Arc::new(CachedRoutingGroupReadRepository::new(inner.clone()));
let barrier = Arc::new(Barrier::new(TASKS + 1));
let mut tasks = Vec::with_capacity(TASKS);
for _ in 0..TASKS {
let repository = Arc::clone(&repository);
let barrier = Arc::clone(&barrier);
tasks.push(tokio::spawn(async move {
barrier.wait().await;
repository.has_any_routing_group_binding().await
}));
}
barrier.wait().await;
inner.wait_until_started().await;
wait_for_same_key_inflight_participants(&repository, TASKS).await;
inner.release.add_permits(TASKS);
for task in tasks {
assert!(task
.await
.expect("request task should finish")
.expect("shared load should succeed"));
}
assert_eq!(inner.calls(), 1);
assert!(repository
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.is_empty());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_same_key_followers_share_leader_error() {
const TASKS: usize = 32;
let inner = Arc::new(ControlledRoutingGroupReadRepository::new(
ControlledHasAnyBindingBehavior::WaitThenError,
));
let repository = Arc::new(CachedRoutingGroupReadRepository::new(inner.clone()));
let barrier = Arc::new(Barrier::new(TASKS + 1));
let mut tasks = Vec::with_capacity(TASKS);
for _ in 0..TASKS {
let repository = Arc::clone(&repository);
let barrier = Arc::clone(&barrier);
tasks.push(tokio::spawn(async move {
barrier.wait().await;
repository.has_any_routing_group_binding().await
}));
}
barrier.wait().await;
inner.wait_until_started().await;
wait_for_same_key_inflight_participants(&repository, TASKS).await;
inner.release.add_permits(TASKS);
for task in tasks {
let error = task
.await
.expect("request task should finish")
.expect_err("shared load should fail");
assert!(matches!(
error,
DataLayerError::TimedOut(message) if message == "shared routing load error"
));
}
assert_eq!(inner.calls(), 1);
}
#[tokio::test]
async fn cancelled_leader_wakes_follower_for_retry() {
let inner = Arc::new(ControlledRoutingGroupReadRepository::new(
ControlledHasAnyBindingBehavior::FirstPendingThenTrue,
));
let repository = Arc::new(CachedRoutingGroupReadRepository::new(inner.clone()));
let leader_repository = Arc::clone(&repository);
let leader =
tokio::spawn(async move { leader_repository.has_any_routing_group_binding().await });
inner.wait_until_started().await;
let follower_repository = Arc::clone(&repository);
let follower =
tokio::spawn(async move { follower_repository.has_any_routing_group_binding().await });
wait_for_same_key_inflight_participants(&repository, 2).await;
leader.abort();
let _ = leader.await;
assert!(tokio::time::timeout(Duration::from_secs(1), follower)
.await
.expect("follower should not remain stuck after leader cancellation")
.expect("follower task should finish")
.expect("retried load should succeed"));
assert_eq!(inner.calls(), 2);
}
#[tokio::test]
async fn clear_wakes_followers_and_rejects_old_generation_publication() {
let inner = Arc::new(ControlledRoutingGroupReadRepository::new(
ControlledHasAnyBindingBehavior::FirstWaitFalseThenTrue,
));
let repository = Arc::new(CachedRoutingGroupReadRepository::new(inner.clone()));
let leader_repository = Arc::clone(&repository);
let leader =
tokio::spawn(async move { leader_repository.has_any_routing_group_binding().await });
inner.wait_until_started().await;
let follower_repository = Arc::clone(&repository);
let follower =
tokio::spawn(async move { follower_repository.has_any_routing_group_binding().await });
wait_for_same_key_inflight_participants(&repository, 2).await;
repository.clear_local_cache();
assert!(tokio::time::timeout(Duration::from_secs(1), follower)
.await
.expect("clear should wake the follower")
.expect("follower task should finish")
.expect("new-generation load should succeed"));
assert_eq!(inner.calls(), 2);
inner.release.add_permits(1);
assert!(!leader
.await
.expect("old leader task should finish")
.expect("old leader load should succeed"));
assert!(repository
.has_any_routing_group_binding()
.await
.expect("new-generation value should remain cached"));
assert_eq!(inner.calls(), 2);
}
#[test]
fn capacity_full_cancelled_follower_can_retry_after_repeated_clear() {
let inner = Arc::new(CountingRoutingGroupReadRepository::default());
let repository = CachedRoutingGroupReadRepository::new(inner);
let key = RoutingGroupCacheKey::HasAnyBinding;
let mut active = Vec::with_capacity(ROUTING_GROUP_CACHE_MAX_INFLIGHT);
for _ in 0..ROUTING_GROUP_CACHE_MAX_INFLIGHT - 1 {
let leader = match repository.register_inflight(&key) {
RoutingGroupInflightRegistration::Leader(guard) => guard,
_ => panic!("each available permit should admit one leader"),
};
active.push(leader);
repository.clear();
}
let current = match repository.register_inflight(&key) {
RoutingGroupInflightRegistration::Leader(guard) => guard,
_ => panic!("the final available permit should admit a leader"),
};
let follower = match repository.register_inflight(&key) {
RoutingGroupInflightRegistration::Follower(state) => state,
_ => panic!("the same-key request should follow at full capacity"),
};
assert_eq!(repository.admission.available_permits(), 0);
assert!(matches!(
repository.register_inflight(&RoutingGroupCacheKey::ListGroups),
RoutingGroupInflightRegistration::Saturated
));
drop(current);
assert!(matches!(
repository.follower_completion(&follower),
Some(RoutingGroupInflightCompletion::Cancelled)
));
let mut replacement = match repository.register_inflight(&key) {
RoutingGroupInflightRegistration::Leader(guard) => guard,
_ => panic!("cancelled follower retry should use the released permit"),
};
replacement.finish(RoutingGroupInflightCompletion::Cancelled);
assert_eq!(repository.admission.available_permits(), 1);
}
}
+627 -154
View File
@@ -5,7 +5,8 @@ use super::{
AdminBillingRuleWriteInput, AdminPaymentOrderListQuery, AdminRedeemCodeBatchListQuery,
AdminRedeemCodeListQuery, AdminWalletLedgerQuery, AdminWalletListQuery,
AdminWalletRefundRequestListQuery, AnnouncementListQuery, AuditLogListQuery,
BackgroundTaskListQuery, BackgroundTaskSummary, BillingModelContextCacheKey, BillingPlanRecord,
BackgroundTaskListQuery, BackgroundTaskSummary, BillingModelContextCacheKey,
BillingModelContextCacheState, BillingModelContextInflightState, BillingPlanRecord,
BillingPlanWriteInput, CompleteAdminWalletRefundInput, CreateAdminRedeemCodeBatchInput,
CreateAdminRedeemCodeBatchResult, CreateAnnouncementRecord, CreateManualWalletRechargeInput,
CreatePlanPurchaseOrderInput, CreatePlanPurchaseOrderOutcome, CreateWalletRechargeOrderInput,
@@ -57,38 +58,93 @@ fn normalize_optional_billing_context_cache_part(value: Option<&str>) -> Option<
.map(ToOwned::to_owned)
}
enum BillingModelContextInflightRegistration {
Leader(u64),
Follower,
Bypass,
enum BillingModelContextInflightRegistration<'a> {
Leader(BillingModelContextInflightGuard<'a>),
Follower(std::sync::Arc<BillingModelContextInflightState>),
Saturated,
}
struct BillingModelContextInflightGuard<'a> {
state: &'a GatewayDataState,
key: Option<BillingModelContextCacheKey>,
token: u64,
inflight_state: std::sync::Arc<BillingModelContextInflightState>,
admission: Option<tokio::sync::OwnedSemaphorePermit>,
}
impl<'a> BillingModelContextInflightGuard<'a> {
fn new(state: &'a GatewayDataState, key: BillingModelContextCacheKey, token: u64) -> Self {
fn new(
state: &'a GatewayDataState,
key: BillingModelContextCacheKey,
inflight_state: std::sync::Arc<BillingModelContextInflightState>,
admission: tokio::sync::OwnedSemaphorePermit,
) -> Self {
Self {
state,
key: Some(key),
token,
inflight_state,
admission: Some(admission),
}
}
fn finish(&mut self) {
if let Some(key) = self.key.take() {
self.state
.finish_billing_model_context_inflight(&key, self.token);
fn epoch(&self) -> u64 {
self.inflight_state.epoch
}
fn finish(&mut self, error: Option<DataLayerError>) {
let removed = self.key.take().and_then(|key| {
self.state.finish_billing_model_context_inflight(
&key,
&self.inflight_state,
self.admission.take(),
)
});
self.admission.take();
if let Some(removed) = removed {
removed.complete(error.map_or(Ok(()), Err));
}
}
}
impl Drop for BillingModelContextInflightGuard<'_> {
fn drop(&mut self) {
self.finish();
self.finish(None);
}
}
impl BillingModelContextInflightState {
fn complete(&self, result: Result<(), DataLayerError>) {
if self.completion.set(result).is_ok() {
self.notify.notify_waiters();
}
}
async fn wait(&self) -> Result<(), DataLayerError> {
loop {
if let Some(result) = self.completion.get() {
return result.clone();
}
let mut notified = Box::pin(self.notify.notified());
notified.as_mut().enable();
if let Some(result) = self.completion.get() {
return result.clone();
}
notified.await;
}
}
}
impl Default for BillingModelContextCacheState {
fn default() -> Self {
Self {
entries: aether_cache::ExpiringMap::default(),
inflight: std::sync::Mutex::new(std::collections::HashMap::new()),
epoch: std::sync::atomic::AtomicU64::new(0),
mutation: std::sync::Mutex::new(()),
admission: std::sync::Arc::new(tokio::sync::Semaphore::new(
GatewayDataState::BILLING_MODEL_CONTEXT_CACHE_MAX_INFLIGHT,
)),
}
}
}
@@ -98,6 +154,7 @@ impl GatewayDataState {
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;
const BILLING_MODEL_CONTEXT_CACHE_MAX_INFLIGHT: usize = 4096;
#[cfg(not(test))]
const BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
#[cfg(test)]
@@ -1143,6 +1200,63 @@ impl GatewayDataState {
.await
}
pub(crate) async fn upsert_first_byte_usage(
&self,
usage: UpsertUsageRecord,
) -> Result<(), DataLayerError> {
crate::request_diagnostics::observe_db_operation(
"usage_first_byte_upsert",
self.database_pool_summary(),
async {
match &self.usage_writer {
Some(repository) => repository.upsert_first_byte(usage).await,
None => Ok(()),
}
},
)
.await
}
pub(crate) async fn upsert_first_byte_usage_many(
&self,
usages: Vec<UpsertUsageRecord>,
) -> Result<(), DataLayerError> {
if usages.is_empty() {
return Ok(());
}
crate::request_diagnostics::observe_db_operation(
"usage_first_byte_upsert_batch",
self.database_pool_summary(),
async {
match &self.usage_writer {
Some(repository) => repository.upsert_first_byte_many(usages).await,
None => Ok(()),
}
},
)
.await
}
pub(crate) async fn upsert_pending_usage_many(
&self,
usages: Vec<UpsertUsageRecord>,
) -> Result<(), DataLayerError> {
if usages.is_empty() {
return Ok(());
}
crate::request_diagnostics::observe_db_operation(
"usage_pending_upsert_batch",
self.database_pool_summary(),
async {
match &self.usage_writer {
Some(repository) => repository.upsert_pending_many(usages).await,
None => Ok(()),
}
},
)
.await
}
#[allow(dead_code)]
pub(crate) async fn rebuild_api_key_usage_stats(&self) -> Result<u64, DataLayerError> {
match &self.usage_writer {
@@ -1832,54 +1946,52 @@ impl GatewayDataState {
return Ok(value);
}
loop {
let notified = self.billing_model_context_cache.inflight_notify.notified();
match self.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Bypass => {
let load_epoch = self
.billing_model_context_cache
.epoch
.load(std::sync::atomic::Ordering::Acquire);
return self
.load_billing_model_context_by_name(
key,
provider_id,
provider_api_key_id,
global_model_name,
load_epoch,
)
.await;
BillingModelContextInflightRegistration::Saturated => {
return Err(DataLayerError::TimedOut(format!(
"billing model context cache admission saturated for {key:?}"
)));
}
BillingModelContextInflightRegistration::Follower => {
if timeout(
BillingModelContextInflightRegistration::Follower(inflight_state) => {
match timeout(
Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT,
notified,
inflight_state.wait(),
)
.await
.is_err()
{
self.expire_billing_model_context_inflight(&key);
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(_) => self.expire_billing_model_context_inflight(&key, &inflight_state),
}
if let Some(value) = self.cached_billing_model_context(&key) {
return Ok(value);
}
continue;
}
BillingModelContextInflightRegistration::Leader(token) => {
let mut guard = BillingModelContextInflightGuard::new(self, key.clone(), token);
let load_epoch = self
.billing_model_context_cache
.epoch
.load(std::sync::atomic::Ordering::Acquire);
let result = self
.load_billing_model_context_by_name(
BillingModelContextInflightRegistration::Leader(mut guard) => {
if let Some(value) = self.cached_billing_model_context(&key) {
return Ok(value);
}
let load_epoch = guard.epoch();
let result = match timeout(
Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT,
self.load_billing_model_context_by_name(
key,
provider_id,
provider_api_key_id,
global_model_name,
load_epoch,
)
.await;
guard.finish();
&guard.inflight_state,
),
)
.await
{
Ok(result) => result,
Err(_) => Err(DataLayerError::TimedOut(
"billing model context load timed out".to_string(),
)),
};
guard.finish(result.as_ref().err().cloned());
return result;
}
}
@@ -1901,54 +2013,52 @@ impl GatewayDataState {
return Ok(value);
}
loop {
let notified = self.billing_model_context_cache.inflight_notify.notified();
match self.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Bypass => {
let load_epoch = self
.billing_model_context_cache
.epoch
.load(std::sync::atomic::Ordering::Acquire);
return self
.load_billing_model_context_by_model_id(
key,
provider_id,
provider_api_key_id,
model_id,
load_epoch,
)
.await;
BillingModelContextInflightRegistration::Saturated => {
return Err(DataLayerError::TimedOut(format!(
"billing model context cache admission saturated for {key:?}"
)));
}
BillingModelContextInflightRegistration::Follower => {
if timeout(
BillingModelContextInflightRegistration::Follower(inflight_state) => {
match timeout(
Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT,
notified,
inflight_state.wait(),
)
.await
.is_err()
{
self.expire_billing_model_context_inflight(&key);
Ok(Ok(())) => {}
Ok(Err(error)) => return Err(error),
Err(_) => self.expire_billing_model_context_inflight(&key, &inflight_state),
}
if let Some(value) = self.cached_billing_model_context(&key) {
return Ok(value);
}
continue;
}
BillingModelContextInflightRegistration::Leader(token) => {
let mut guard = BillingModelContextInflightGuard::new(self, key.clone(), token);
let load_epoch = self
.billing_model_context_cache
.epoch
.load(std::sync::atomic::Ordering::Acquire);
let result = self
.load_billing_model_context_by_model_id(
BillingModelContextInflightRegistration::Leader(mut guard) => {
if let Some(value) = self.cached_billing_model_context(&key) {
return Ok(value);
}
let load_epoch = guard.epoch();
let result = match timeout(
Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT,
self.load_billing_model_context_by_model_id(
key,
provider_id,
provider_api_key_id,
model_id,
load_epoch,
)
.await;
guard.finish();
&guard.inflight_state,
),
)
.await
{
Ok(result) => result,
Err(_) => Err(DataLayerError::TimedOut(
"billing model context load timed out".to_string(),
)),
};
guard.finish(result.as_ref().err().cloned());
return result;
}
}
@@ -1962,6 +2072,7 @@ impl GatewayDataState {
provider_api_key_id: Option<&str>,
global_model_name: &str,
load_epoch: u64,
load_flight: &std::sync::Arc<BillingModelContextInflightState>,
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
crate::request_diagnostics::observe_db_operation(
"billing_model_context",
@@ -1972,11 +2083,16 @@ impl GatewayDataState {
let value = repository
.find_model_context(provider_id, provider_api_key_id, global_model_name)
.await?;
self.remember_billing_model_context(key, value.clone(), load_epoch);
self.remember_billing_model_context(
key,
value.clone(),
load_epoch,
load_flight,
);
Ok(value)
}
None => {
self.remember_billing_model_context(key, None, load_epoch);
self.remember_billing_model_context(key, None, load_epoch, load_flight);
Ok(None)
}
}
@@ -1992,6 +2108,7 @@ impl GatewayDataState {
provider_api_key_id: Option<&str>,
model_id: &str,
load_epoch: u64,
load_flight: &std::sync::Arc<BillingModelContextInflightState>,
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
crate::request_diagnostics::observe_db_operation(
"billing_model_context",
@@ -2006,11 +2123,16 @@ impl GatewayDataState {
model_id,
)
.await?;
self.remember_billing_model_context(key, value.clone(), load_epoch);
self.remember_billing_model_context(
key,
value.clone(),
load_epoch,
load_flight,
);
Ok(value)
}
None => {
self.remember_billing_model_context(key, None, load_epoch);
self.remember_billing_model_context(key, None, load_epoch, load_flight);
Ok(None)
}
}
@@ -2022,44 +2144,91 @@ impl GatewayDataState {
fn register_billing_model_context_inflight(
&self,
key: &BillingModelContextCacheKey,
) -> BillingModelContextInflightRegistration {
match self.billing_model_context_cache.inflight.lock() {
Ok(mut inflight) => {
if inflight.contains_key(key) {
return BillingModelContextInflightRegistration::Follower;
}
let token = self
.billing_model_context_cache
.next_inflight_token
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
inflight.insert(key.clone(), token);
BillingModelContextInflightRegistration::Leader(token)
}
Err(_) => BillingModelContextInflightRegistration::Bypass,
) -> BillingModelContextInflightRegistration<'_> {
let mut inflight = self
.billing_model_context_cache
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if let Some(inflight_state) = inflight.get(key) {
return BillingModelContextInflightRegistration::Follower(std::sync::Arc::clone(
inflight_state,
));
}
if inflight.len() >= Self::BILLING_MODEL_CONTEXT_CACHE_MAX_INFLIGHT {
return BillingModelContextInflightRegistration::Saturated;
}
let Ok(admission) =
std::sync::Arc::clone(&self.billing_model_context_cache.admission).try_acquire_owned()
else {
return BillingModelContextInflightRegistration::Saturated;
};
let inflight_state = std::sync::Arc::new(BillingModelContextInflightState {
epoch: self
.billing_model_context_cache
.epoch
.load(std::sync::atomic::Ordering::Acquire),
completion: std::sync::OnceLock::new(),
notify: tokio::sync::Notify::new(),
});
inflight.insert(key.clone(), std::sync::Arc::clone(&inflight_state));
BillingModelContextInflightRegistration::Leader(BillingModelContextInflightGuard::new(
self,
key.clone(),
inflight_state,
admission,
))
}
fn finish_billing_model_context_inflight(
&self,
key: &BillingModelContextCacheKey,
inflight_state: &std::sync::Arc<BillingModelContextInflightState>,
admission: Option<tokio::sync::OwnedSemaphorePermit>,
) -> Option<std::sync::Arc<BillingModelContextInflightState>> {
let mut inflight = self
.billing_model_context_cache
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
drop(admission);
if inflight
.get(key)
.is_some_and(|current| std::sync::Arc::ptr_eq(current, inflight_state))
{
inflight.remove(key)
} else {
None
}
}
fn finish_billing_model_context_inflight(&self, key: &BillingModelContextCacheKey, token: u64) {
let mut removed = false;
if let Ok(mut inflight) = self.billing_model_context_cache.inflight.lock() {
if inflight.get(key).copied() == Some(token) {
inflight.remove(key);
removed = true;
fn expire_billing_model_context_inflight(
&self,
key: &BillingModelContextCacheKey,
inflight_state: &std::sync::Arc<BillingModelContextInflightState>,
) {
let _mutation = self
.billing_model_context_cache
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let removed = {
let mut inflight = self
.billing_model_context_cache
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if inflight
.get(key)
.is_some_and(|current| std::sync::Arc::ptr_eq(current, inflight_state))
{
inflight.remove(key)
} else {
None
}
}
if removed {
self.billing_model_context_cache
.inflight_notify
.notify_waiters();
}
}
fn expire_billing_model_context_inflight(&self, key: &BillingModelContextCacheKey) {
let mut removed = false;
if let Ok(mut inflight) = self.billing_model_context_cache.inflight.lock() {
removed = inflight.remove(key).is_some();
}
if removed {
};
drop(_mutation);
if let Some(removed) = removed {
tracing::warn!(
event_name = "billing_model_context_cache_inflight_expired",
log_type = "ops",
@@ -2067,9 +2236,7 @@ impl GatewayDataState {
wait_timeout_ms = Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT.as_millis() as u64,
"gateway billing model context cache expired stale inflight load"
);
self.billing_model_context_cache
.inflight_notify
.notify_waiters();
removed.complete(Ok(()));
}
}
@@ -2079,13 +2246,7 @@ impl GatewayDataState {
) -> Option<Option<StoredBillingModelContext>> {
self.billing_model_context_cache
.entries
.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())
})
.get_fresh(key, Self::BILLING_MODEL_CONTEXT_CACHE_TTL)
}
fn remember_billing_model_context(
@@ -2093,12 +2254,13 @@ impl GatewayDataState {
key: BillingModelContextCacheKey,
value: Option<StoredBillingModelContext>,
load_epoch: u64,
load_flight: &std::sync::Arc<BillingModelContextInflightState>,
) {
let mut cache = self
let _mutation = self
.billing_model_context_cache
.entries
.write()
.expect("billing model context cache lock");
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if load_epoch
!= self
.billing_model_context_cache
@@ -2107,44 +2269,53 @@ impl GatewayDataState {
{
return;
}
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);
}
let inflight = self
.billing_model_context_cache
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !inflight
.get(&key)
.is_some_and(|current| std::sync::Arc::ptr_eq(current, load_flight))
{
return;
}
cache.insert(key, (Instant::now(), value));
self.billing_model_context_cache.entries.insert(
key,
value,
Self::BILLING_MODEL_CONTEXT_CACHE_TTL,
Self::BILLING_MODEL_CONTEXT_CACHE_MAX_ENTRIES,
);
}
pub(super) fn clear_billing_model_context_cache(&self) {
let _mutation = self
.billing_model_context_cache
.mutation
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
self.billing_model_context_cache
.epoch
.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
self.billing_model_context_cache
.entries
.write()
.expect("billing model context cache lock")
.clear();
let mut cleared_inflight = false;
if let Ok(mut inflight) = self.billing_model_context_cache.inflight.lock() {
cleared_inflight = !inflight.is_empty();
inflight.clear();
}
if cleared_inflight {
self.billing_model_context_cache.entries.clear();
let inflight_states = self
.billing_model_context_cache
.inflight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.drain()
.map(|(_, state)| state)
.collect::<Vec<_>>();
drop(_mutation);
if !inflight_states.is_empty() {
tracing::warn!(
event_name = "billing_model_context_cache_inflight_cleared",
log_type = "ops",
"gateway billing model context cache cleared in-flight loads"
);
self.billing_model_context_cache
.inflight_notify
.notify_waiters();
for inflight_state in inflight_states {
inflight_state.complete(Ok(()));
}
}
}
@@ -2572,11 +2743,14 @@ mod tests {
use aether_data_contracts::repository::global_models::{
StoredAdminGlobalModel, StoredPublicGlobalModel, UpdateAdminGlobalModelRecord,
};
use aether_data_contracts::DataLayerError;
use async_trait::async_trait;
use serde_json::json;
use tokio::sync::Barrier;
use super::GatewayDataState;
use super::{
BillingModelContextCacheKey, BillingModelContextInflightRegistration, GatewayDataState,
};
struct SlowBillingContextRepository {
calls: AtomicUsize,
@@ -2590,6 +2764,13 @@ mod tests {
release_first_read: Barrier,
}
struct ConcurrentBillingContextRepository {
calls: AtomicUsize,
context: StoredBillingModelContext,
entered: Barrier,
release: Barrier,
}
#[async_trait]
impl BillingReadRepository for SlowBillingContextRepository {
async fn find_model_context(
@@ -2628,6 +2809,21 @@ mod tests {
}
}
#[async_trait]
impl BillingReadRepository for ConcurrentBillingContextRepository {
async fn find_model_context(
&self,
_provider_id: &str,
_provider_api_key_id: Option<&str>,
_global_model_name: &str,
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
self.calls.fetch_add(1, Ordering::AcqRel);
self.entered.wait().await;
self.release.wait().await;
Ok(Some(self.context.clone()))
}
}
fn billing_context() -> StoredBillingModelContext {
StoredBillingModelContext::new(
"provider-1".to_string(),
@@ -2649,6 +2845,283 @@ mod tests {
.expect("billing context should build")
}
fn billing_cache_key(global_model_name: impl Into<String>) -> BillingModelContextCacheKey {
BillingModelContextCacheKey::ByGlobalModelName {
provider_id: "provider-1".to_string(),
provider_api_key_id: Some("key-1".to_string()),
global_model_name: global_model_name.into(),
}
}
#[tokio::test]
async fn billing_model_context_cancelled_leader_cannot_lose_follower_wakeup() {
let state = GatewayDataState::default();
let key = billing_cache_key("lost-wakeup");
let leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let follower = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Follower(inflight_state) => inflight_state,
_ => panic!("second registration should follow"),
};
// Complete before wait() is constructed or polled. A bare global
// notify_waiters() broadcast loses this ordering.
drop(leader);
tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("cancelled flight must release an unpolled follower")
.expect("leader cancellation should allow a retry");
assert!(matches!(
state.register_billing_model_context_inflight(&key),
BillingModelContextInflightRegistration::Leader(_)
));
}
#[tokio::test]
async fn billing_model_context_failed_flight_fans_out_error() {
let state = GatewayDataState::default();
let key = billing_cache_key("failed-flight");
let mut leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let followers = (0..2)
.map(
|_| match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Follower(inflight_state) => {
inflight_state
}
_ => panic!("same-key registration should follow"),
},
)
.collect::<Vec<_>>();
leader.finish(Some(DataLayerError::Sql(
"forced billing context load failure".to_string(),
)));
for follower in followers {
let error = tokio::time::timeout(Duration::from_millis(100), follower.wait())
.await
.expect("failed flight should release every follower")
.expect_err("follower should receive the leader failure");
assert_eq!(
error.to_string(),
"sql error: forced billing context load failure"
);
}
assert!(state
.billing_model_context_cache
.inflight
.lock()
.unwrap()
.is_empty());
}
#[tokio::test]
async fn billing_model_context_clear_wakes_old_follower_without_removing_replacement() {
let state = GatewayDataState::default();
let key = billing_cache_key("clear-replacement");
let old_leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_follower = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Follower(inflight_state) => inflight_state,
_ => panic!("second registration should follow"),
};
let old_epoch = old_leader.epoch();
state.clear_billing_model_context_cache();
let replacement_leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("clear should allow a replacement leader"),
};
let replacement_follower = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Follower(inflight_state) => inflight_state,
_ => panic!("registration behind replacement should follow"),
};
assert_ne!(replacement_leader.epoch(), old_epoch);
drop(old_leader);
assert!(state
.billing_model_context_cache
.inflight
.lock()
.unwrap()
.get(&key)
.is_some_and(|current| std::sync::Arc::ptr_eq(
current,
&replacement_leader.inflight_state
)));
tokio::time::timeout(Duration::from_millis(100), old_follower.wait())
.await
.expect("clear should wake the invalidated flight")
.expect("clear should allow an immediate retry");
drop(replacement_leader);
tokio::time::timeout(Duration::from_millis(100), replacement_follower.wait())
.await
.expect("old guard must not strand the replacement follower")
.expect("replacement completion should succeed");
}
#[tokio::test]
async fn billing_model_context_timeout_expiration_allows_replacement() {
let state = GatewayDataState::default();
let key = billing_cache_key("timeout-replacement");
let old_leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_follower = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Follower(inflight_state) => inflight_state,
_ => panic!("second registration should follow"),
};
state.expire_billing_model_context_inflight(&key, &old_follower);
old_follower
.wait()
.await
.expect("expired flight should permit a retry");
let replacement_leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("timeout should allow a replacement leader"),
};
drop(old_leader);
assert!(state
.billing_model_context_cache
.inflight
.lock()
.unwrap()
.get(&key)
.is_some_and(|current| std::sync::Arc::ptr_eq(
current,
&replacement_leader.inflight_state
)));
}
#[test]
fn billing_model_context_expired_leader_cannot_publish_over_replacement() {
let state = GatewayDataState::default();
let key = billing_cache_key("timeout-publication-replacement");
let old_leader = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("first registration should lead"),
};
let old_flight = std::sync::Arc::clone(&old_leader.inflight_state);
let load_epoch = old_leader.epoch();
state.expire_billing_model_context_inflight(&key, &old_flight);
let replacement = match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => guard,
_ => panic!("expiration should allow a replacement leader"),
};
assert_eq!(replacement.epoch(), load_epoch);
let mut fresh = billing_context();
fresh.default_price_per_request = Some(2.0);
state.remember_billing_model_context(
key.clone(),
Some(fresh),
replacement.epoch(),
&replacement.inflight_state,
);
let mut stale = billing_context();
stale.default_price_per_request = Some(1.0);
state.remember_billing_model_context(key.clone(), Some(stale), load_epoch, &old_flight);
let cached = state
.cached_billing_model_context(&key)
.expect("replacement should publish")
.expect("billing context should exist");
assert_eq!(cached.default_price_per_request, Some(2.0));
}
#[test]
fn billing_model_context_inflight_limit_rejects_only_new_keys() {
let state = GatewayDataState::default();
let mut leaders =
Vec::with_capacity(GatewayDataState::BILLING_MODEL_CONTEXT_CACHE_MAX_INFLIGHT);
for index in 0..GatewayDataState::BILLING_MODEL_CONTEXT_CACHE_MAX_INFLIGHT {
let key = billing_cache_key(format!("model-{index}"));
match state.register_billing_model_context_inflight(&key) {
BillingModelContextInflightRegistration::Leader(guard) => leaders.push(guard),
_ => panic!("unique key below the hard limit should lead"),
}
}
assert!(matches!(
state.register_billing_model_context_inflight(&billing_cache_key("overflow")),
BillingModelContextInflightRegistration::Saturated
));
assert!(matches!(
state.register_billing_model_context_inflight(&billing_cache_key("model-0")),
BillingModelContextInflightRegistration::Follower(_)
));
assert_eq!(
state
.billing_model_context_cache
.inflight
.lock()
.unwrap()
.len(),
GatewayDataState::BILLING_MODEL_CONTEXT_CACHE_MAX_INFLIGHT
);
drop(leaders);
assert!(state
.billing_model_context_cache
.inflight
.lock()
.unwrap()
.is_empty());
}
#[tokio::test]
async fn billing_model_context_different_keys_load_concurrently() {
let repository = Arc::new(ConcurrentBillingContextRepository {
calls: AtomicUsize::new(0),
context: billing_context(),
entered: Barrier::new(3),
release: Barrier::new(3),
});
let state = Arc::new(GatewayDataState::with_billing_reader_for_tests(
repository.clone(),
));
let task_a = {
let state = Arc::clone(&state);
tokio::spawn(async move {
state
.find_billing_model_context("provider-1", Some("key-1"), "model-a")
.await
})
};
let task_b = {
let state = Arc::clone(&state);
tokio::spawn(async move {
state
.find_billing_model_context("provider-1", Some("key-1"), "model-b")
.await
})
};
tokio::time::timeout(Duration::from_secs(1), repository.entered.wait())
.await
.expect("different cache keys should enter the repository concurrently");
assert_eq!(repository.calls.load(Ordering::Acquire), 2);
repository.release.wait().await;
task_a
.await
.expect("first lookup should join")
.expect("first lookup should succeed");
task_b
.await
.expect("second lookup should join")
.expect("second lookup should succeed");
}
#[tokio::test]
async fn billing_model_context_cache_coalesces_concurrent_loads() {
let repository = Arc::new(SlowBillingContextRepository {
@@ -17,10 +17,10 @@ use super::{
OAuthProviderWriteRepository, PoolMemberScoreWriteRepository, PoolScoreReadRepository,
ProviderCatalogReadRepository, ProviderCatalogWriteRepository, ProviderQuotaReadRepository,
ProviderQuotaWriteRepository, ProxyNodeReadRepository, ProxyNodeWriteRepository,
RequestCandidateReadRepository, RequestCandidateWriteRepository, SettlementWriteRepository,
StoredSystemConfigEntry, StoredUserPreferenceRecord, UsageReadRepository, UsageWriteRepository,
UserReadRepository, VideoTaskReadRepository, VideoTaskWriteRepository, WalletReadRepository,
WalletWriteRepository,
RequestCandidateReadRepository, RequestCandidateWriteRepository, RoutingGroupReadRepository,
RoutingGroupWriteRepository, SettlementWriteRepository, StoredSystemConfigEntry,
StoredUserPreferenceRecord, UsageReadRepository, UsageWriteRepository, UserReadRepository,
VideoTaskReadRepository, VideoTaskWriteRepository, WalletReadRepository, WalletWriteRepository,
};
mod announcements;
@@ -867,6 +867,16 @@ impl GatewayDataState {
self
}
#[cfg(test)]
pub(crate) fn with_routing_group_repository_for_tests<T>(mut self, repository: Arc<T>) -> Self
where
T: RoutingGroupReadRepository + RoutingGroupWriteRepository + 'static,
{
self.routing_group_reader = Some(repository.clone());
self.routing_group_writer = Some(repository);
self
}
#[cfg(test)]
pub(crate) fn with_auth_api_key_reader(
mut self,
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::ai_serving::AiSurfaceFinalizeError;
use crate::constants::*;
use crate::insert_header_if_missing;
#[derive(Debug)]
#[derive(Debug, Clone)]
pub(crate) enum GatewayError {
UpstreamUnavailable {
trace_id: String,
@@ -12,6 +12,7 @@ use aether_contracts::{
EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER, EXECUTION_REQUEST_FOLLOW_REDIRECTS_HEADER,
TRANSPORT_BACKEND_BROWSER_WREQ, TRANSPORT_HTTP_MODE_AUTO, TRANSPORT_POOL_SCOPE_KEY,
};
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyRuntimeMetadataUpdate;
use aether_provider_pool::{
build_chatgpt_web_pool_quota_request, normalize_chatgpt_web_image_quota_limit,
ProviderPoolQuotaRequestSpec,
@@ -46,6 +47,7 @@ const CHATGPT_WEB_SEC_CH_UA: &str =
const CHATGPT_WEB_BROWSER_PROFILE: &str = "chrome143";
const CHATGPT_WEB_QUOTA_REFRESH_TIMEOUT_MS: u64 = 30_000;
const CHATGPT_WEB_QUOTA_REFRESH_PROXY_TIMEOUT_MS: u64 = 60_000;
const RUNTIME_METADATA_CAS_MAX_ATTEMPTS: usize = 16;
const GPT_IMAGE2_TOKEN_MIN_PIXELS: u64 = 655_360;
const GPT_IMAGE2_TOKEN_MAX_PIXELS: u64 = 8_294_400;
const GPT_IMAGE2_TOKEN_MAX_EDGE: u64 = 3_840;
@@ -1057,57 +1059,81 @@ async fn apply_chatgpt_web_image_quota_request_delta(
if key_id.is_empty() || provider_id.is_empty() {
return Ok(false);
}
let Some(mut latest_key) = state
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await
.map_err(|err| err.into_message())?
.into_iter()
.find(|key| key.id == key_id && key.provider_id == provider_id)
else {
return Ok(false);
};
let mut metadata = latest_key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let now_unix_secs = current_unix_secs();
let request_dedup_key = chatgpt_web_image_quota_request_delta_dedup_key(plan);
if !apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
latest_key.status_snapshot.as_ref(),
now_unix_secs,
request_dedup_key.as_deref(),
) {
return Ok(false);
for attempt in 0..RUNTIME_METADATA_CAS_MAX_ATTEMPTS {
let Some(mut latest_key) = state
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await
.map_err(|err| err.into_message())?
.into_iter()
.find(|key| key.id == key_id && key.provider_id == provider_id)
else {
return Ok(false);
};
let expected_namespace_value = latest_key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.cloned();
let mut metadata = expected_namespace_value
.as_ref()
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
let now_unix_secs = current_unix_secs();
if !apply_chatgpt_web_image_quota_request_delta_to_metadata(
&mut metadata,
latest_key.status_snapshot.as_ref(),
now_unix_secs,
request_dedup_key.as_deref(),
) {
return Ok(false);
}
let namespace_value = Value::Object(metadata);
let updated_upstream_metadata = merge_provider_metadata_object(
latest_key.upstream_metadata.as_ref(),
"chatgpt_web",
namespace_value.clone(),
);
latest_key.upstream_metadata = updated_upstream_metadata;
latest_key.status_snapshot = sync_provider_key_quota_status_snapshot(
latest_key.status_snapshot.as_ref(),
"chatgpt_web",
latest_key.upstream_metadata.as_ref(),
"image_request_local",
);
latest_key.status_snapshot = sync_provider_key_oauth_status_snapshot(
latest_key.status_snapshot.as_ref(),
&latest_key,
);
latest_key.updated_at_unix_secs = Some(now_unix_secs);
let persisted = state
.update_provider_catalog_key_runtime_metadata(
&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: latest_key.id.clone(),
namespace: "chatgpt_web".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: namespace_value,
status_snapshot_patch: provider_operational_status_patch(
latest_key.status_snapshot.as_ref(),
),
updated_at_unix_secs: latest_key.updated_at_unix_secs,
},
)
.await
.map_err(|err| err.into_message())?;
if persisted {
return Ok(true);
}
if attempt + 1 < RUNTIME_METADATA_CAS_MAX_ATTEMPTS {
let backoff_us = 50_u64.saturating_mul((attempt + 1) as u64).min(1_000);
tokio::time::sleep(Duration::from_micros(backoff_us)).await;
}
}
let updated_upstream_metadata = merge_provider_metadata_object(
latest_key.upstream_metadata.as_ref(),
"chatgpt_web",
Value::Object(metadata),
);
latest_key.upstream_metadata = updated_upstream_metadata;
latest_key.status_snapshot = sync_provider_key_quota_status_snapshot(
latest_key.status_snapshot.as_ref(),
"chatgpt_web",
latest_key.upstream_metadata.as_ref(),
"image_request_local",
);
latest_key.status_snapshot =
sync_provider_key_oauth_status_snapshot(latest_key.status_snapshot.as_ref(), &latest_key);
latest_key.updated_at_unix_secs = Some(now_unix_secs);
Ok(state
.update_provider_catalog_key_runtime_state(&latest_key)
.await
.map_err(|err| err.into_message())?
.is_some())
Ok(false)
}
fn apply_chatgpt_web_image_quota_request_delta_to_metadata(
@@ -1427,12 +1453,10 @@ async fn refresh_chatgpt_web_image_quota_after_success(
let body_json = execution_result_json(&result).map_err(|err| err.to_string())?;
let now_unix_secs = current_unix_secs();
let Some(mut metadata) =
parse_chatgpt_web_conversation_init_response(&body_json, now_unix_secs)
let Some(metadata) = parse_chatgpt_web_conversation_init_response(&body_json, now_unix_secs)
else {
return Ok(false);
};
let Some(latest_key) = state
.read_provider_catalog_keys_by_ids(&key_ids)
.await
@@ -1442,9 +1466,17 @@ async fn refresh_chatgpt_web_image_quota_after_success(
else {
return Ok(false);
};
let expected_namespace_value = latest_key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("chatgpt_web"))
.cloned();
let mut metadata = metadata.clone();
normalize_chatgpt_web_image_quota_limit(&mut metadata, latest_key.upstream_metadata.as_ref());
let mut updated_key = latest_key;
let namespace_value = metadata.clone();
let updated_upstream_metadata = merge_provider_metadata_object(
updated_key.upstream_metadata.as_ref(),
"chatgpt_web",
@@ -1465,11 +1497,35 @@ async fn refresh_chatgpt_web_image_quota_after_success(
sync_provider_key_oauth_status_snapshot(updated_key.status_snapshot.as_ref(), &updated_key);
updated_key.updated_at_unix_secs = Some(now_unix_secs);
Ok(state
.update_provider_catalog_key_runtime_state(&updated_key)
let persisted = state
.update_provider_catalog_key_runtime_metadata(&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: updated_key.id.clone(),
namespace: "chatgpt_web".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: namespace_value,
status_snapshot_patch: provider_operational_status_patch(
updated_key.status_snapshot.as_ref(),
),
updated_at_unix_secs: updated_key.updated_at_unix_secs,
})
.await
.map_err(|err| err.into_message())?
.is_some())
.map_err(|err| err.into_message())?;
if persisted {
return state
.update_provider_catalog_key_oauth_runtime_state(
&updated_key.id,
updated_key.oauth_invalid_at_unix_secs,
updated_key.oauth_invalid_reason.as_deref(),
None,
updated_key.updated_at_unix_secs,
)
.await
.map_err(|err| err.into_message());
}
// The conversation/init response is an authoritative snapshot. A
// conflict means a newer local delta won; do not overwrite it with
// this stale response. The next refresh will observe the new value.
Ok(false)
}
fn build_chatgpt_web_image_quota_refresh_plan(
@@ -1557,6 +1613,18 @@ fn merge_provider_metadata_object(
Some(Value::Object(merged))
}
fn provider_operational_status_patch(status_snapshot: Option<&Value>) -> Value {
let mut patch = Map::new();
if let Some(snapshot) = status_snapshot.and_then(Value::as_object) {
for field in ["quota", "oauth"] {
if let Some(value) = snapshot.get(field) {
patch.insert(field.to_string(), value.clone());
}
}
}
Value::Object(patch)
}
fn chatgpt_web_image_quota_snapshot_window(
status_snapshot: Option<&Value>,
) -> Option<&Map<String, Value>> {
@@ -1,4 +1,5 @@
use std::collections::BTreeMap;
use std::time::Duration;
use aether_contracts::ExecutionPlan;
use serde_json::{Map, Value};
@@ -10,6 +11,7 @@ const RESPONSE_HEADER_RULES_KEY: &str = "response_header_rules";
const RESPONSE_HEADER_RULES_CAMEL_KEY: &str = "responseHeaderRules";
const PROVIDER_RESPONSE_HEADERS_CONTEXT_KEY: &str = "provider_response_headers";
const RESPONSE_HEADER_RULE_PROTECTED_KEYS: &[&str] = &["content-length"];
const RESPONSE_HEADER_RULES_CACHE_TTL: Duration = Duration::from_secs(5);
fn endpoint_response_header_rules_from_config(config: Option<&Value>) -> Option<&Value> {
let config = config?.as_object()?;
@@ -26,18 +28,28 @@ async fn read_endpoint_response_header_rules(state: &AppState, endpoint_id: &str
}
let endpoint_id = endpoint_id.to_string();
let endpoint_id_for_load = endpoint_id.clone();
match state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id))
.endpoint_response_header_rules_cache
.get_or_load(endpoint_id, RESPONSE_HEADER_RULES_CACHE_TTL, || async {
state
.read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id_for_load))
.await
.map(|endpoints| {
endpoints.into_iter().next().and_then(|endpoint| {
endpoint_response_header_rules_from_config(endpoint.config.as_ref())
.cloned()
})
})
})
.await
{
Ok(endpoints) => endpoints.into_iter().next().and_then(|endpoint| {
endpoint_response_header_rules_from_config(endpoint.config.as_ref()).cloned()
}),
Ok(rules) => rules,
Err(err) => {
warn!(
event_name = "response_header_rules_endpoint_read_failed",
log_type = "ops",
endpoint_id = %endpoint_id,
endpoint_id = %endpoint_id_for_load,
error = ?err,
"gateway failed to read endpoint response header rules; skipping response header edits"
);
File diff suppressed because it is too large Load Diff
@@ -368,7 +368,12 @@ async fn record_stream_sync_failure(
let payload_seed = build_sync_terminal_usage_payload_seed(payload);
state
.usage_runtime
.record_sync_terminal(state.data.as_ref(), context_seed, payload_seed);
.record_sync_terminal(
state.usage_lifecycle_data_state().as_ref(),
context_seed,
payload_seed,
)
.await;
}
let terminal_unix_secs = current_request_candidate_unix_ms();
record_report_request_candidate_status(
@@ -261,7 +261,7 @@ async fn record_sync_attempt_forced_terminal_state(
state
.usage_runtime
.record_terminal_event_direct(
state.data.as_ref(),
state.usage_lifecycle_data_state().as_ref(),
UsageEvent::new(usage_event_type, plan.request_id.clone(), usage_data),
)
.await;
@@ -315,7 +315,7 @@ fn record_sync_response_started(
ttfb_ms: u64,
) {
state.usage_runtime.record_stream_started_immediate_async(
state.data.as_ref(),
state.usage_lifecycle_data_state().as_ref(),
lifecycle_seed,
status_code,
Some(ExecutionTelemetry {
@@ -349,9 +349,10 @@ fn record_sync_execution_active(
candidate_started_unix_ms: u64,
) {
let lifecycle_seed = build_lifecycle_usage_seed(plan, report_context);
state
.usage_runtime
.record_sync_active_immediate_async(state.data.as_ref(), lifecycle_seed);
state.usage_runtime.record_sync_active_immediate_async(
state.usage_lifecycle_data_state().as_ref(),
lifecycle_seed,
);
if let Some(snapshot) = snapshot_local_request_candidate_status(plan, report_context) {
spawn_sync_candidate_status_update(
@@ -370,7 +371,7 @@ fn record_sync_execution_active(
}
}
fn record_sync_terminal_usage(
async fn record_sync_terminal_usage(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&serde_json::Value>,
@@ -385,17 +386,22 @@ fn record_sync_terminal_usage(
let payload_seed = build_sync_terminal_usage_payload_seed(payload);
state
.usage_runtime
.record_sync_terminal(state.data.as_ref(), context_seed, payload_seed);
.record_sync_terminal(
state.usage_lifecycle_data_state().as_ref(),
context_seed,
payload_seed,
)
.await;
}
fn record_sync_terminal_usage_and_disarm_guard(
async fn record_sync_terminal_usage_and_disarm_guard(
state: &AppState,
plan: &ExecutionPlan,
report_context: Option<&serde_json::Value>,
payload: &GatewaySyncReportRequest,
terminal_guard: &mut SyncAttemptTerminalGuard,
) {
record_sync_terminal_usage(state, plan, report_context, payload);
record_sync_terminal_usage(state, plan, report_context, payload).await;
terminal_guard.disarm();
}
@@ -1675,7 +1681,7 @@ async fn execute_execution_runtime_sync_impl(
.unwrap_or_else(|| "-".to_string());
let candidate_started_unix_secs = current_request_candidate_unix_ms();
let lifecycle_seed = build_lifecycle_usage_seed(&plan, report_context.as_ref());
let usage_data = state.data.as_ref().clone();
let usage_data = state.usage_lifecycle_data_state().as_ref().clone();
state
.usage_runtime
.record_pending_direct(&usage_data, lifecycle_seed)
@@ -2433,7 +2439,8 @@ async fn execute_execution_runtime_sync_impl(
implicit_finalize.payload.report_context.as_ref(),
usage_payload,
&mut terminal_guard,
);
)
.await;
if let Some(report_payload) = implicit_finalize.outcome.background_report {
spawn_sync_report(state.clone(), report_payload);
} else {
@@ -2486,7 +2493,8 @@ async fn execute_execution_runtime_sync_impl(
payload.report_context.as_ref(),
usage_payload,
&mut terminal_guard,
);
)
.await;
if let Some(report_payload) = outcome.background_report {
spawn_sync_report(state.clone(), report_payload);
} else {
@@ -2532,7 +2540,8 @@ async fn execute_execution_runtime_sync_impl(
original_report_context.as_ref(),
&report_payload,
&mut terminal_guard,
);
)
.await;
if let Some(snapshot) = local_task_snapshot {
let _ = state.upsert_video_task_snapshot(&snapshot).await?;
state.video_tasks.record_snapshot(snapshot);
@@ -2566,7 +2575,8 @@ async fn execute_execution_runtime_sync_impl(
payload.report_context.as_ref(),
&payload,
&mut terminal_guard,
);
)
.await;
state
.video_tasks
.apply_finalize_mutation(request_path, payload.report_kind.as_str());
@@ -2612,7 +2622,8 @@ async fn execute_execution_runtime_sync_impl(
payload.report_context.as_ref(),
&payload,
&mut terminal_guard,
);
)
.await;
if background_error_report_kind.is_some() {
spawn_sync_report(state.clone(), payload);
} else {
@@ -2638,7 +2649,8 @@ async fn execute_execution_runtime_sync_impl(
payload.report_context.as_ref(),
&payload,
&mut terminal_guard,
);
)
.await;
let response =
submit_local_core_error_or_sync_finalize(state, trace_id, decision, payload).await?;
return Ok(Some(attach_control_metadata_headers(
@@ -2673,7 +2685,8 @@ async fn execute_execution_runtime_sync_impl(
usage_payload.report_context.as_ref(),
&usage_payload,
&mut terminal_guard,
);
)
.await;
let response = attach_control_metadata_headers(
build_client_response_from_parts(
status_code,
@@ -3089,7 +3102,7 @@ mod tests {
ensure_execution_request_candidate_slot(&state, &mut plan, &mut report_context).await;
let started_at = current_request_candidate_unix_ms();
state.usage_runtime.record_pending(
state.data.as_ref(),
state.usage_lifecycle_data_state().as_ref(),
build_lifecycle_usage_seed(&plan, report_context.as_ref()),
);
record_local_request_candidate_status(
@@ -3220,7 +3233,7 @@ mod tests {
state
.usage_runtime
.record_pending_direct(
state.data.as_ref(),
state.usage_lifecycle_data_state().as_ref(),
build_lifecycle_usage_seed(&plan, report_context.as_ref()),
)
.await;
@@ -4,7 +4,7 @@ use std::future::Future;
use std::io::Read;
use std::io::Write;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex as StdMutex};
use std::sync::{Arc, LazyLock, Mutex as StdMutex, OnceLock, RwLock as StdRwLock};
use std::time::{Duration, Instant};
use aether_contracts::{
@@ -74,6 +74,9 @@ const DIRECT_H2C_POOL_MAX_IDLE_PER_HOST_ENV: &str =
const DIRECT_H2C_TARGET_STREAMS_PER_CLIENT_ENV: &str =
"AETHER_GATEWAY_DIRECT_H2C_TARGET_STREAMS_PER_CLIENT";
const DIRECT_H2C_SENDER_SELECT_WINDOW_ENV: &str = "AETHER_GATEWAY_DIRECT_H2C_SENDER_SELECT_WINDOW";
const DIRECT_H2C_ADAPTIVE_WINDOW_ENV: &str = "AETHER_GATEWAY_DIRECT_H2C_ADAPTIVE_WINDOW";
const DIRECT_H2C_DRIVER_RUNTIME_THREADS_ENV: &str =
"AETHER_GATEWAY_DIRECT_H2C_DRIVER_RUNTIME_THREADS";
const DIRECT_H2C_PREWARM_URLS_ENV: &str = "AETHER_GATEWAY_DIRECT_H2C_PREWARM_URLS";
const DIRECT_H2C_PREWARM_READY_ENV: &str = "AETHER_GATEWAY_DIRECT_H2C_PREWARM_READY";
const DIRECT_H2C_PREWARM_CONNECT_TIMEOUT_MS_ENV: &str =
@@ -87,6 +90,10 @@ const DEFAULT_HTTP1_TARGET_STREAMS_PER_CLIENT: usize = 512;
const DEFAULT_DIRECT_H2C_POOL_MAX_IDLE_PER_HOST: usize = 512;
const DEFAULT_DIRECT_H2C_TARGET_STREAMS_PER_CLIENT: usize = 128;
const DEFAULT_DIRECT_H2C_SENDER_SELECT_WINDOW: usize = 4;
const MAX_DIRECT_H2C_DRIVER_RUNTIME_THREADS: usize = 16;
const DIRECT_H2C_DRIVER_RUNTIME_MAX_BLOCKING_THREADS: usize = 16;
const DIRECT_H2C_DRIVER_RUNTIME_STACK_BYTES: usize = 2 * 1024 * 1024;
const DIRECT_H2C_DRIVER_RUNTIME_THREAD_NAME: &str = "aether-h2c-driver";
const DEFAULT_DIRECT_REQWEST_SYNC_WARM_CLIENTS: usize = 4;
const MAX_DIRECT_REQWEST_SYNC_WARM_CLIENTS: usize = 16;
const MAX_DIRECT_H2C_CLIENT_SHARDS: usize = 512;
@@ -334,8 +341,8 @@ static DIRECT_H2C_CLIENT_CACHE: LazyLock<
> = LazyLock::new(|| StdMutex::new(HashMap::new()));
static DIRECT_H2C_SENDER_CACHE: LazyLock<
StdMutex<HashMap<DirectHyperH2cClientCacheKey, Arc<DirectHyperH2cSenderCacheCell>>>,
> = LazyLock::new(|| StdMutex::new(HashMap::new()));
StdRwLock<HashMap<DirectHyperH2cClientCacheKey, Arc<DirectHyperH2cSenderCacheCell>>>,
> = LazyLock::new(|| StdRwLock::new(HashMap::new()));
static DIRECT_H2C_POOL_MAX_IDLE_PER_HOST: LazyLock<usize> = LazyLock::new(|| {
env_positive_usize(DIRECT_H2C_POOL_MAX_IDLE_PER_HOST_ENV)
@@ -1560,24 +1567,47 @@ fn direct_h2c_sender_cache_cell(
cache_key: &DirectHyperH2cClientCacheKey,
) -> Arc<DirectHyperH2cSenderCacheCell> {
let cache_lock_started_at = Instant::now();
if let Ok(mut cache) = DIRECT_H2C_SENDER_CACHE.lock() {
if let Ok(cache) = DIRECT_H2C_SENDER_CACHE.read() {
if let Some(cell) = cache.get(cache_key) {
let cell = Arc::clone(cell);
drop(cache);
observe_gateway_stage_ms(
"direct_reqwest_client_cache_lock",
cache_lock_started_at.elapsed().as_millis() as u64,
);
DIRECT_H2C_SENDER_CACHE_METRICS
.hits
.fetch_add(1, Ordering::Relaxed);
return cell;
}
}
// Recheck after acquiring the write lock so simultaneous first requests
// still share one OnceCell and one connection warmup.
if let Ok(mut cache) = DIRECT_H2C_SENDER_CACHE.write() {
let (cell, hit) = match cache.get(cache_key) {
Some(cell) => (Arc::clone(cell), true),
None => {
let cell = Arc::new(TokioOnceCell::new());
cache.insert(cache_key.clone(), Arc::clone(&cell));
(cell, false)
}
};
drop(cache);
observe_gateway_stage_ms(
"direct_reqwest_client_cache_lock",
cache_lock_started_at.elapsed().as_millis() as u64,
);
if let Some(cell) = cache.get(cache_key) {
if hit {
DIRECT_H2C_SENDER_CACHE_METRICS
.hits
.fetch_add(1, Ordering::Relaxed);
Arc::clone(cell)
} else {
DIRECT_H2C_SENDER_CACHE_METRICS
.misses
.fetch_add(1, Ordering::Relaxed);
let cell = Arc::new(TokioOnceCell::new());
cache.insert(cache_key.clone(), Arc::clone(&cell));
cell
}
return cell;
} else {
observe_gateway_stage_ms(
"direct_reqwest_client_cache_lock",
@@ -1586,8 +1616,8 @@ fn direct_h2c_sender_cache_cell(
DIRECT_H2C_SENDER_CACHE_METRICS
.misses
.fetch_add(1, Ordering::Relaxed);
Arc::new(TokioOnceCell::new())
}
Arc::new(TokioOnceCell::new())
}
fn direct_h2c_client_cache_key(
@@ -1627,6 +1657,33 @@ async fn build_direct_h2c_sender_cache_entry_from_cache_key(
async fn connect_direct_h2c_sender(
cache_key: &DirectHyperH2cClientCacheKey,
) -> Result<DirectHyperH2cSender, ExecutionRuntimeTransportError> {
let driver_runtime = configured_direct_h2c_driver_runtime()?;
connect_direct_h2c_sender_on_runtime(cache_key, driver_runtime).await
}
async fn connect_direct_h2c_sender_on_runtime(
cache_key: &DirectHyperH2cClientCacheKey,
driver_runtime: Option<&'static tokio::runtime::Runtime>,
) -> Result<DirectHyperH2cSender, ExecutionRuntimeTransportError> {
let Some(driver_runtime) = driver_runtime else {
return connect_direct_h2c_sender_on_current_runtime(cache_key).await;
};
let cache_key = cache_key.clone();
driver_runtime
.handle()
.spawn(async move { connect_direct_h2c_sender_on_current_runtime(&cache_key).await })
.await
.map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
"direct H2C connect task failed: {err}"
))
})?
}
async fn connect_direct_h2c_sender_on_current_runtime(
cache_key: &DirectHyperH2cClientCacheKey,
) -> Result<DirectHyperH2cSender, ExecutionRuntimeTransportError> {
let upstream = reqwest::Url::parse(&cache_key.upstream_origin).map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format!(
@@ -1678,11 +1735,13 @@ async fn connect_direct_h2c_sender(
})?;
let io = TokioIo::new(stream);
let mut builder = hyper::client::conn::http2::Builder::new(TokioExecutor::new());
builder.adaptive_window(true);
builder.adaptive_window(direct_h2c_adaptive_window_enabled());
let (sender, connection) = builder.handshake(io).await.map_err(|err| {
ExecutionRuntimeTransportError::UpstreamRequest(format_hyper_error_chain(&err))
})?;
tokio::spawn(async move {
// Connect, handshake, and drive the connection on the same runtime so the
// socket remains registered with the reactor polling the H2 connection.
spawn_direct_h2c_driver_task(None, async move {
if let Err(err) = connection.await {
tracing::debug!(
error = %format_hyper_error_chain(&err),
@@ -1780,6 +1839,82 @@ fn direct_h2c_sender_select_window() -> usize {
*DIRECT_H2C_SENDER_SELECT_WINDOW
}
fn direct_h2c_adaptive_window_enabled() -> bool {
std::env::var(DIRECT_H2C_ADAPTIVE_WINDOW_ENV)
.ok()
.map(|value| matches_truthy_env_value(value.trim()))
.unwrap_or(true)
}
fn direct_h2c_driver_runtime_threads() -> Option<usize> {
parse_direct_h2c_driver_runtime_threads(
std::env::var(DIRECT_H2C_DRIVER_RUNTIME_THREADS_ENV)
.ok()
.as_deref(),
)
}
fn parse_direct_h2c_driver_runtime_threads(value: Option<&str>) -> Option<usize> {
value
.and_then(|value| value.trim().parse::<usize>().ok())
.filter(|threads| *threads > 0)
.map(|threads| threads.clamp(1, MAX_DIRECT_H2C_DRIVER_RUNTIME_THREADS))
}
fn configured_direct_h2c_driver_runtime(
) -> Result<Option<&'static tokio::runtime::Runtime>, ExecutionRuntimeTransportError> {
direct_h2c_driver_runtime_threads()
.map(direct_h2c_driver_runtime)
.transpose()
}
fn direct_h2c_driver_runtime(
worker_threads: usize,
) -> Result<&'static tokio::runtime::Runtime, ExecutionRuntimeTransportError> {
struct RuntimeEntry {
runtime: &'static tokio::runtime::Runtime,
worker_threads: usize,
}
static RUNTIME: OnceLock<Result<RuntimeEntry, String>> = OnceLock::new();
let entry = RUNTIME.get_or_init(|| {
tokio::runtime::Builder::new_multi_thread()
.enable_all()
.worker_threads(worker_threads)
.max_blocking_threads(DIRECT_H2C_DRIVER_RUNTIME_MAX_BLOCKING_THREADS)
.thread_name(DIRECT_H2C_DRIVER_RUNTIME_THREAD_NAME)
.thread_stack_size(DIRECT_H2C_DRIVER_RUNTIME_STACK_BYTES)
.build()
.map(|runtime| RuntimeEntry {
runtime: Box::leak(Box::new(runtime)),
worker_threads,
})
.map_err(|err| format!("failed to build direct H2C driver runtime: {err}"))
});
match entry {
Ok(entry) if entry.worker_threads == worker_threads => Ok(entry.runtime),
Ok(entry) => Err(ExecutionRuntimeTransportError::UpstreamRequest(format!(
"direct H2C driver runtime was initialized with {} worker threads, not {worker_threads}",
entry.worker_threads
))),
Err(err) => Err(ExecutionRuntimeTransportError::UpstreamRequest(err.clone())),
}
}
fn spawn_direct_h2c_driver_task<F>(
driver_runtime: Option<&'static tokio::runtime::Runtime>,
task: F,
) -> tokio::task::JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{
match driver_runtime {
Some(runtime) => runtime.handle().spawn(task),
None => tokio::spawn(task),
}
}
async fn send_via_direct_h2c_fast_path(
plan: &ExecutionPlan,
method: reqwest::Method,
@@ -3017,7 +3152,7 @@ pub(crate) fn direct_reqwest_client_cache_metric_samples() -> Vec<MetricSample>
h2c_sender_in_flight,
h2c_sender_max_in_flight,
) = DIRECT_H2C_SENDER_CACHE
.lock()
.read()
.map_or((0, 0, 0, 0, 0, 0, 0), |cache| {
let entries = cache.len() as u64;
let ready_entries = cache
@@ -4188,6 +4323,74 @@ mod tests {
assert_eq!(super::direct_h2c_client_shard_count(), 7);
}
#[test]
fn direct_h2c_adaptive_window_respects_explicit_env() {
let _guard = direct_reqwest_env_lock();
{
let _adaptive = set_test_env_var(super::DIRECT_H2C_ADAPTIVE_WINDOW_ENV, "0");
assert!(!super::direct_h2c_adaptive_window_enabled());
}
let _adaptive = set_test_env_var(super::DIRECT_H2C_ADAPTIVE_WINDOW_ENV, "true");
assert!(super::direct_h2c_adaptive_window_enabled());
}
#[test]
fn direct_h2c_driver_runtime_threads_are_opt_in_and_bounded() {
assert_eq!(super::parse_direct_h2c_driver_runtime_threads(None), None);
assert_eq!(
super::parse_direct_h2c_driver_runtime_threads(Some("")),
None
);
assert_eq!(
super::parse_direct_h2c_driver_runtime_threads(Some("invalid")),
None
);
assert_eq!(
super::parse_direct_h2c_driver_runtime_threads(Some("0")),
None
);
assert_eq!(
super::parse_direct_h2c_driver_runtime_threads(Some(" 1 ")),
Some(1)
);
assert_eq!(
super::parse_direct_h2c_driver_runtime_threads(Some("16")),
Some(16)
);
assert_eq!(
super::parse_direct_h2c_driver_runtime_threads(Some("128")),
Some(super::MAX_DIRECT_H2C_DRIVER_RUNTIME_THREADS)
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_h2c_driver_task_defaults_to_current_runtime_and_can_use_dedicated_runtime() {
let current_runtime_id = tokio::runtime::Handle::current().id();
let default_runtime_id = super::spawn_direct_h2c_driver_task(None, async {
tokio::runtime::Handle::current().id()
})
.await
.expect("default direct H2C driver task should join");
assert_eq!(default_runtime_id, current_runtime_id);
let driver_runtime = super::direct_h2c_driver_runtime(1)
.expect("dedicated direct H2C driver runtime should build");
let (dedicated_runtime_id, thread_name) =
super::spawn_direct_h2c_driver_task(Some(driver_runtime), async {
(
tokio::runtime::Handle::current().id(),
std::thread::current().name().map(ToOwned::to_owned),
)
})
.await
.expect("dedicated direct H2C driver task should join");
assert_ne!(dedicated_runtime_id, current_runtime_id);
assert_eq!(
thread_name.as_deref(),
Some(super::DIRECT_H2C_DRIVER_RUNTIME_THREAD_NAME)
);
}
#[test]
fn direct_h2c_prewarm_urls_parse_env_list() {
let _guard = direct_reqwest_env_lock();
@@ -6285,6 +6488,148 @@ mod tests {
.expect("h2c prior-knowledge client should build");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_sync_execution_runtime_uses_h2c_prior_knowledge_on_wire() {
let _guard = direct_reqwest_env_lock();
let _shards = set_test_env_var(super::DIRECT_REQWEST_H2_CLIENT_SHARDS_ENV, "1");
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("server should accept");
let service = hyper::service::service_fn(
|request: hyper::Request<hyper::body::Incoming>| async move {
let body = if request.version() == hyper::Version::HTTP_2 {
Bytes::from_static(br#"{"http_version":"h2c"}"#)
} else {
Bytes::from_static(br#"{"http_version":"unexpected"}"#)
};
Ok::<_, std::convert::Infallible>(
hyper::Response::builder()
.header(hyper::header::CONTENT_TYPE, "application/json")
.body(http_body_util::Full::new(body))
.expect("response should build"),
)
},
);
hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(hyper_util::rt::TokioIo::new(stream), service)
.await
.expect("H2C server connection should run");
});
let result = DirectSyncExecutionRuntime::new()
.execute_sync(&ExecutionPlan {
request_id: "req-h2c-wire-1".into(),
candidate_id: Some("cand-h2c-wire-1".into()),
provider_name: Some("mock".into()),
provider_id: "prov-h2c-wire".into(),
endpoint_id: "ep-h2c-wire".into(),
key_id: "key-h2c-wire".into(),
method: "POST".into(),
url: format!("http://{addr}/chat"),
headers: BTreeMap::from([("content-type".into(), "application/json".into())]),
content_type: Some("application/json".into()),
content_encoding: None,
body: RequestBody::from_json(json!({"model": "mock-model"})),
stream: false,
client_api_format: "openai:chat".into(),
provider_api_format: "openai:chat".into(),
model_name: Some("mock-model".into()),
proxy: None,
transport_profile: Some(ResolvedTransportProfile {
profile_id: "mock-h2c-wire".into(),
backend: TRANSPORT_BACKEND_REQWEST_RUSTLS.into(),
http_mode: TRANSPORT_HTTP_MODE_H2C_PRIOR_KNOWLEDGE.into(),
pool_scope: "key".into(),
header_fingerprint: None,
extra: None,
}),
timeouts: Some(ExecutionTimeouts {
connect_ms: Some(5_000),
total_ms: Some(LOCAL_HTTP_SUCCESS_TIMEOUT_MS),
..ExecutionTimeouts::default()
}),
})
.await
.expect("H2C prior-knowledge request should succeed");
server.abort();
let _ = server.await;
assert_eq!(result.status_code, 200);
assert_eq!(
result.body.and_then(|body| body.json_body),
Some(json!({"http_version": "h2c"}))
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn direct_h2c_connection_driver_can_run_on_dedicated_runtime() {
let listener = crate::test_support::bind_loopback_listener()
.await
.expect("listener should bind");
let addr = listener.local_addr().expect("local addr should resolve");
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("server should accept");
let service = hyper::service::service_fn(
|request: hyper::Request<hyper::body::Incoming>| async move {
assert_eq!(request.version(), hyper::Version::HTTP_2);
Ok::<_, std::convert::Infallible>(
hyper::Response::builder()
.header("x-aether-driver-runtime", "dedicated")
.body(http_body_util::Full::new(Bytes::from_static(b"ok")))
.expect("response should build"),
)
},
);
hyper::server::conn::http2::Builder::new(hyper_util::rt::TokioExecutor::new())
.serve_connection(hyper_util::rt::TokioIo::new(stream), service)
.await
.expect("H2C server connection should run");
});
let driver_runtime = super::direct_h2c_driver_runtime(1)
.expect("dedicated direct H2C driver runtime should build");
let cache_key = super::DirectHyperH2cClientCacheKey {
upstream_origin: format!("http://{addr}"),
connect_timeout_ms: Some(5_000),
pool_max_idle_per_host: 1,
};
let sender = super::connect_direct_h2c_sender_on_runtime(&cache_key, Some(driver_runtime))
.await
.expect("dedicated-runtime H2C sender should connect");
let slot = super::DirectHyperH2cSenderSlot::new(sender);
let request = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(format!("http://{addr}/chat"))
.header(hyper::header::HOST, addr.to_string())
.body(http_body_util::Full::new(Bytes::from_static(b"{}")))
.expect("request should build");
let response = super::send_hyper_h2c_request(
slot.acquire(),
request,
Some(std::time::Duration::from_secs(5)),
)
.await
.expect("dedicated-runtime H2C request should succeed");
assert_eq!(response.status(), hyper::StatusCode::OK);
assert_eq!(response.version(), hyper::Version::HTTP_2);
assert_eq!(
response
.headers()
.get("x-aether-driver-runtime")
.and_then(|value| value.to_str().ok()),
Some("dedicated")
);
drop(response);
drop(slot);
server.abort();
let _ = server.await;
}
#[test]
fn direct_sync_execution_runtime_rejects_unsupported_transport_backend() {
let profile = ResolvedTransportProfile {
@@ -363,7 +363,6 @@ where
let Some(attempt) = next_attempt else {
break;
};
last_attempted = Some((attempt.execution_plan().clone(), attempt.report_context()));
let execute_started_at = std::time::Instant::now();
let response = match port.execute_attempt(&attempt).await {
Ok(response) => response,
@@ -387,6 +386,10 @@ where
);
return Ok(LocalExecutionRequestOutcome::responded(response));
}
// Only retain a deep plan/context snapshot when this candidate really
// failed and exhaustion reporting will need it.
last_attempted = Some((attempt.execution_plan().clone(), attempt.report_context()));
}
let Some((last_plan, last_report_context)) = last_attempted else {
@@ -447,7 +450,7 @@ where
type Error = GatewayError;
async fn execute_attempt(&self, attempt: &T) -> Result<Option<Self::Response>, Self::Error> {
let plan = attempt.execution_plan().clone();
let plan = attempt.execution_plan();
let report_context = attempt.report_context();
let candidate_index = parse_request_candidate_report_context(report_context.as_ref())
.and_then(|context| context.candidate_index)
@@ -478,24 +481,34 @@ where
{
return Ok(Some(response));
}
prewarm_direct_reqwest_candidate_client(&plan);
let watchdog_plan = plan.clone();
let watchdog_report_context = report_context.clone();
prewarm_direct_reqwest_candidate_client(plan);
// The attempt owns the canonical report context. Borrow it for the
// watchdog; only third-party/synthesized attempts using the default
// trait implementation need an owned fallback clone.
let watchdog_report_context_owned = if attempt.report_context_ref().is_none() {
report_context.clone()
} else {
None
};
let watchdog_report_context = attempt
.report_context_ref()
.or(watchdog_report_context_owned.as_ref());
let execution_state = self.state.clone();
let execution_trace_id = self.trace_id.to_string();
let execution_plan_kind = self.plan_kind.to_string();
let execution_decision = self.decision.clone();
let execution_report_kind = attempt.report_kind();
let execution_plan = plan.clone();
let mut response = execute_stream_candidate_with_watchdog(
self.state,
self.trace_id,
self.plan_kind,
&watchdog_plan,
watchdog_report_context.as_ref(),
plan,
watchdog_report_context,
move || async move {
execute_execution_runtime_stream(
&execution_state,
plan,
execution_plan,
execution_trace_id.as_str(),
&execution_decision,
execution_plan_kind.as_str(),
@@ -507,7 +520,7 @@ where
)
.await?;
if let Some(response) = response.as_mut() {
attach_redaction_execution_candidate(response, watchdog_plan.candidate_id.as_deref());
attach_redaction_execution_candidate(response, plan.candidate_id.as_deref());
}
Ok(response)
}
+2 -2
View File
@@ -345,7 +345,7 @@ pub(crate) async fn record_failed_usage_for_exhausted_request(
state
.usage_runtime
.record_terminal_event_direct(
state.data.as_ref(),
state.usage_lifecycle_data_state().as_ref(),
UsageEvent::new(UsageEventType::Failed, request_id, data),
)
.await;
@@ -484,7 +484,7 @@ pub(crate) async fn record_failed_usage_for_runtime_miss_request(
state
.usage_runtime
.record_terminal_event_direct(
state.data.as_ref(),
state.usage_lifecycle_data_state().as_ref(),
UsageEvent::new(UsageEventType::Failed, request_id, data),
)
.await;
@@ -191,8 +191,7 @@ pub(super) async fn build_admin_monitoring_system_status_response(
.unwrap_or(usize::MAX);
let tunnel = state.tunnel.stats();
let usage_counter_snapshot = state
.data
.read_usage_counter_health()
.read_cached_usage_counter_health()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let usage_counter =
@@ -29,8 +29,7 @@ async fn build_usage_counter_health_payload(
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let snapshot = state
.as_ref()
.data
.read_usage_counter_health()
.read_cached_usage_counter_health()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
Ok(build_admin_usage_counter_health_payload(
@@ -2,6 +2,7 @@ use crate::handlers::admin::provider::shared::paths::admin_reset_cycle_stats_key
use crate::handlers::admin::request::{AdminAppState, AdminRequestContext};
use crate::handlers::admin::shared::provider_key_status_snapshot_payload;
use crate::GatewayError;
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyStatusSnapshotUpdate;
use axum::{
body::{Body, Bytes},
http,
@@ -33,7 +34,7 @@ pub(super) async fn maybe_handle(
let Some(key_id) = admin_reset_cycle_stats_key_id(request_context.path()) else {
return Ok(Some(not_found_response("Key 不存在")));
};
let Some(mut key) = state
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
@@ -65,11 +66,17 @@ pub(super) async fn maybe_handle(
return Ok(Some(bad_request_response("当前账号没有可重置的周期窗口")));
}
key.status_snapshot = Some(status_snapshot);
key.updated_at_unix_secs = Some(now_unix_secs);
let Some(_) = state.update_provider_catalog_key(&key).await? else {
let quota = status_snapshot.get("quota").cloned().unwrap_or(Value::Null);
if !state
.update_provider_catalog_key_status_snapshot(&ProviderCatalogKeyStatusSnapshotUpdate {
key_id: key.id.clone(),
status_snapshot_patch: json!({"quota":quota}),
updated_at_unix_secs: Some(now_unix_secs),
})
.await?
{
return Ok(None);
};
}
Ok(Some(
Json(json!({
@@ -82,9 +82,22 @@ pub(super) async fn maybe_handle(
Ok(record) => record,
Err(detail) => return Ok(Some(bad_request_response(detail))),
};
let Some(updated) = state.update_provider_catalog_key(&updated_record).await? else {
let Some(mut updated) = state.update_provider_catalog_key(&updated_record).await? else {
return Ok(None);
};
if updated_record.learned_rpm_limit != existing_key.learned_rpm_limit {
let Some(reloaded) = state
.set_provider_catalog_key_learned_rpm_limit(
&key_id,
updated_record.learned_rpm_limit,
updated_record.updated_at_unix_secs,
)
.await?
else {
return Ok(None);
};
updated = reloaded;
}
let should_overwrite_allowed_models_immediately =
admin_provider_key_update_requires_immediate_model_fetch(&existing_key, &updated);
let updated = if should_overwrite_allowed_models_immediately {
@@ -1,5 +1,7 @@
use super::super::super::errors::build_internal_control_error_response;
use super::super::super::provisioning::provider_oauth_token_payload_expires_at_unix_secs;
use super::super::super::provisioning::{
provider_oauth_token_payload_expires_at_unix_secs, seed_provider_oauth_pool_score,
};
use super::super::super::runtime::{
resolve_provider_oauth_runtime_endpoints,
spawn_provider_oauth_account_state_refresh_after_update,
@@ -220,6 +222,25 @@ pub(super) async fn handle_admin_provider_oauth_complete_key(
"Key 不存在",
));
}
if !state
.clear_provider_catalog_key_oauth_invalid_marker(&key_id)
.await?
{
return Ok(build_internal_control_error_response(
http::StatusCode::NOT_FOUND,
"Key 不存在",
));
}
let Some(recovered_key) = state
.reset_provider_catalog_key_recovery_state(&key_id)
.await?
else {
return Ok(build_internal_control_error_response(
http::StatusCode::NOT_FOUND,
"Key 不存在",
));
};
seed_provider_oauth_pool_score(state, &provider.id, &recovered_key, now_unix_secs).await;
spawn_provider_oauth_account_state_refresh_after_update(
state.cloned_app(),
@@ -2,11 +2,16 @@ use super::state::{
decode_jwt_claims, enrich_admin_provider_oauth_auth_config, json_non_empty_string,
json_u64_value,
};
use crate::ai_serving::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
use crate::handlers::admin::admin_provider_pool_config;
use crate::handlers::admin::request::AdminAppState;
use crate::maintenance::ensure_provider_key_pool_scores_for_keys;
use crate::provider_key_auth::provider_active_api_formats;
use crate::GatewayError;
use aether_data_contracts::repository::pool_scores::{
GetPoolMemberScoresByIdsQuery, PoolMemberIdentity,
};
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
@@ -211,14 +216,22 @@ pub(crate) async fn update_existing_provider_oauth_catalog_key(
if updated.fingerprint.is_none() {
updated.fingerprint = grok_oauth_catalog_key_fingerprint(provider_type, auth_config);
}
updated.health_by_format = Some(json!({}));
updated.circuit_breaker_by_format = Some(json!({}));
updated.error_count = Some(0);
if let Some(proxy) = proxy {
updated.proxy = Some(proxy);
}
updated.updated_at_unix_secs = Some(now_unix_secs);
let persisted = state.update_provider_catalog_key(&updated).await?;
if state.update_provider_catalog_key(&updated).await?.is_none() {
return Ok(None);
}
if !state
.clear_provider_catalog_key_oauth_invalid_marker(&updated.id)
.await?
{
return Ok(None);
}
let persisted = state
.reset_provider_catalog_key_recovery_state(&updated.id)
.await?;
if let Some(key) = persisted.as_ref() {
let _ = state
.app()
@@ -229,7 +242,7 @@ pub(crate) async fn update_existing_provider_oauth_catalog_key(
Ok(persisted)
}
async fn seed_provider_oauth_pool_score(
pub(super) async fn seed_provider_oauth_pool_score(
state: &AdminAppState<'_>,
provider_id: &str,
key: &StoredProviderCatalogKey,
@@ -257,38 +270,45 @@ async fn seed_provider_oauth_pool_score(
let Some(pool_config) = admin_provider_pool_config(&provider) else {
return;
};
let endpoints = match state
.list_provider_catalog_endpoints_by_provider_ids(std::slice::from_ref(&provider_id))
if !key.is_active || key.provider_id != provider.id {
return;
}
let identity = PoolMemberIdentity::provider_api_key(provider.id.clone(), key.id.clone());
let scope = provider_key_pool_score_scope();
let score_id = provider_key_pool_score_id(&identity, &scope);
let existing = match state
.app()
.data
.get_pool_member_scores_by_ids(&GetPoolMemberScoresByIdsQuery {
ids: vec![score_id],
})
.await
{
Ok(endpoints) => endpoints,
Ok(mut scores) => scores.pop(),
Err(err) => {
tracing::debug!(
provider_id = %provider_id,
key_id = %key.id,
error = ?err,
"gateway provider oauth provisioning: failed to read endpoints for pool score seed"
"gateway provider oauth provisioning: failed to read existing pool score"
);
return;
}
};
let score_ensure_budget = (pool_config.score_fallback_scan_limit as usize).clamp(1, 50_000);
if let Err(err) = ensure_provider_key_pool_scores_for_keys(
state.as_ref(),
&provider,
&pool_config,
&endpoints,
std::slice::from_ref(key),
let upsert = build_provider_key_pool_score_upsert(
key,
provider.provider_type.as_str(),
existing.as_ref(),
now_unix_secs,
score_ensure_budget,
)
.await
{
pool_config.score_rules,
);
if let Err(err) = state.app().data.upsert_pool_member_score(upsert).await {
tracing::debug!(
provider_id = %provider_id,
key_id = %key.id,
error = ?err,
"gateway provider oauth provisioning: failed to seed pool score row"
"gateway provider oauth provisioning: failed to refresh pool score row"
);
}
}
@@ -14,6 +14,7 @@ use aether_contracts::{
ResolvedTransportProfile, EXECUTION_REQUEST_ACCEPT_INVALID_CERTS_HEADER,
};
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyRuntimeMetadataUpdate, ProviderCatalogKeyStatusSnapshotUpdate,
StoredProviderCatalogEndpoint, StoredProviderCatalogKey,
};
use aether_provider_pool::{ProviderPoolQuotaRequestSpec, ProviderPoolService};
@@ -288,6 +289,30 @@ pub(crate) async fn persist_provider_quota_refresh_state(
oauth_invalid_reason: Option<String>,
encrypted_auth_config: Option<String>,
) -> Result<bool, GatewayError> {
persist_provider_quota_refresh_state_after_read(
state,
key_id,
metadata_update,
oauth_invalid_at_unix_secs,
oauth_invalid_reason,
encrypted_auth_config,
std::future::ready(()),
)
.await
}
async fn persist_provider_quota_refresh_state_after_read<F>(
state: &AdminAppState<'_>,
key_id: &str,
metadata_update: Option<&serde_json::Value>,
oauth_invalid_at_unix_secs: Option<u64>,
oauth_invalid_reason: Option<String>,
encrypted_auth_config: Option<String>,
after_read: F,
) -> Result<bool, GatewayError>
where
F: std::future::Future<Output = ()>,
{
let Some(mut latest_key) = state
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await?
@@ -296,7 +321,11 @@ pub(crate) async fn persist_provider_quota_refresh_state(
else {
return Ok(false);
};
after_read.await;
// Keep the namespace values observed before applying the refresh response;
// each runtime metadata write uses them as its CAS expectation.
let observed_upstream_metadata = latest_key.upstream_metadata.clone();
let mut quota_snapshot_provider_type = None::<String>;
if let Some(metadata_update) = metadata_update {
latest_key.upstream_metadata = Some(merge_upstream_metadata(
@@ -306,8 +335,8 @@ pub(crate) async fn persist_provider_quota_refresh_state(
quota_snapshot_provider_type =
aether_provider_pool::provider_pool_quota_metadata_provider_type(metadata_update);
}
if let Some(encrypted_auth_config) = encrypted_auth_config {
latest_key.encrypted_auth_config = Some(encrypted_auth_config);
if let Some(encrypted_auth_config) = encrypted_auth_config.as_ref() {
latest_key.encrypted_auth_config = Some(encrypted_auth_config.clone());
}
latest_key.oauth_invalid_at_unix_secs = oauth_invalid_at_unix_secs;
latest_key.oauth_invalid_reason = oauth_invalid_reason;
@@ -325,10 +354,91 @@ pub(crate) async fn persist_provider_quota_refresh_state(
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs());
Ok(state
.update_provider_catalog_key(&latest_key)
.await?
.is_some())
let status_patch = provider_quota_refresh_status_patch(latest_key.status_snapshot.as_ref());
let metadata_updates = metadata_update
.and_then(serde_json::Value::as_object)
.map(|updates| {
updates
.iter()
.map(|(namespace, value)| (namespace.clone(), value.clone()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
if metadata_updates.is_empty() {
if !state
.update_provider_catalog_key_oauth_runtime_state(
key_id,
latest_key.oauth_invalid_at_unix_secs,
latest_key.oauth_invalid_reason.as_deref(),
encrypted_auth_config.as_deref(),
latest_key.updated_at_unix_secs,
)
.await?
{
return Ok(false);
}
return state
.update_provider_catalog_key_status_snapshot(&ProviderCatalogKeyStatusSnapshotUpdate {
key_id: key_id.to_string(),
status_snapshot_patch: status_patch,
updated_at_unix_secs: latest_key.updated_at_unix_secs,
})
.await;
}
for (index, (namespace, value)) in metadata_updates.iter().enumerate() {
let patch = if index + 1 == metadata_updates.len() {
status_patch.clone()
} else {
serde_json::json!({})
};
let mut expected = observed_upstream_metadata
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|metadata| metadata.get(namespace))
.cloned();
let persisted = state
.app()
.update_provider_catalog_key_runtime_metadata(
&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: key_id.to_string(),
namespace: namespace.clone(),
expected_upstream_metadata_value: expected.clone(),
upstream_metadata_value: value.clone(),
status_snapshot_patch: patch.clone(),
updated_at_unix_secs: latest_key.updated_at_unix_secs,
},
)
.await?;
if !persisted {
// The refresh response is an authoritative snapshot. Do not
// replay it over a newer namespace after a CAS conflict.
return Ok(false);
}
}
state
.update_provider_catalog_key_oauth_runtime_state(
key_id,
latest_key.oauth_invalid_at_unix_secs,
latest_key.oauth_invalid_reason.as_deref(),
encrypted_auth_config.as_deref(),
latest_key.updated_at_unix_secs,
)
.await
}
fn provider_quota_refresh_status_patch(
status_snapshot: Option<&serde_json::Value>,
) -> serde_json::Value {
let mut patch = serde_json::Map::new();
if let Some(snapshot) = status_snapshot.and_then(serde_json::Value::as_object) {
for field in ["quota", "oauth"] {
if let Some(value) = snapshot.get(field) {
patch.insert(field.to_string(), value.clone());
}
}
}
serde_json::Value::Object(patch)
}
pub(super) async fn execute_provider_quota_plan(
@@ -372,3 +482,95 @@ pub(super) async fn execute_provider_quota_plan(
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::data::GatewayDataState;
use crate::AppState;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogReadRepository, ProviderCatalogWriteRepository, StoredProviderCatalogKey,
};
use serde_json::json;
use std::sync::Arc;
#[tokio::test]
async fn metadata_cas_conflict_does_not_persist_stale_oauth_runtime_state() {
let mut key = StoredProviderCatalogKey::new(
"key-codex-cas".to_string(),
"provider-codex-cas".to_string(),
"Codex CAS".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build");
key.encrypted_auth_config = Some("old-auth-config".to_string());
key.oauth_invalid_at_unix_secs = Some(100);
key.oauth_invalid_reason = Some("old-invalid-reason".to_string());
key.upstream_metadata = Some(json!({"codex":{"remaining":5}}));
key.status_snapshot = Some(json!({"oauth":{"invalid":true}}));
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![],
vec![],
vec![key],
));
let app = AppState::new()
.expect("app should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
&repository,
)),
);
let admin_state = AdminAppState::new(&app);
let concurrent_repository = Arc::clone(&repository);
let metadata_update = json!({"codex":{"remaining":3}});
let persisted = persist_provider_quota_refresh_state_after_read(
&admin_state,
"key-codex-cas",
Some(&metadata_update),
Some(200),
Some("new-invalid-reason".to_string()),
Some("new-auth-config".to_string()),
async move {
assert!(concurrent_repository
.update_key_runtime_metadata(&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: "key-codex-cas".to_string(),
namespace: "codex".to_string(),
expected_upstream_metadata_value: Some(json!({"remaining":5})),
upstream_metadata_value: json!({"remaining":4}),
status_snapshot_patch: json!({}),
updated_at_unix_secs: Some(150),
})
.await
.expect("concurrent metadata update should execute"));
},
)
.await
.expect("quota refresh persistence should not error");
assert!(!persisted, "stale namespace should report a CAS conflict");
let stored = repository
.list_keys_by_ids(&["key-codex-cas".to_string()])
.await
.expect("key should reload")
.pop()
.expect("key should remain");
assert_eq!(
stored.encrypted_auth_config.as_deref(),
Some("old-auth-config")
);
assert_eq!(stored.oauth_invalid_at_unix_secs, Some(100));
assert_eq!(
stored.oauth_invalid_reason.as_deref(),
Some("old-invalid-reason")
);
assert_eq!(
stored.upstream_metadata.as_ref().unwrap()["codex"],
json!({"remaining":4})
);
}
}
@@ -140,6 +140,15 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn reset_provider_catalog_key_error_count(
&self,
key_id: &str,
) -> Result<bool, GatewayError> {
self.app
.reset_provider_catalog_key_error_count(key_id)
.await
}
pub(crate) async fn create_provider_catalog_endpoint(
&self,
endpoint: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogEndpoint,
@@ -177,6 +186,139 @@ impl<'a> AdminAppState<'a> {
self.app.update_provider_catalog_key(key).await
}
pub(crate) async fn compare_and_update_provider_catalog_key_adaptive_state(
&self,
update: &aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyAdaptiveStateUpdate,
) -> Result<bool, GatewayError> {
self.app
.compare_and_update_provider_catalog_key_adaptive_state(update)
.await
}
pub(crate) async fn set_provider_catalog_key_learned_rpm_limit(
&self,
key_id: &str,
learned_rpm_limit: Option<u32>,
updated_at_unix_secs: Option<u64>,
) -> Result<
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyAdaptiveState, ProviderCatalogKeyAdaptiveStateUpdate,
};
for _ in 0..4 {
let Some(current) = self
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await?
.into_iter()
.next()
else {
return Ok(None);
};
let expected = ProviderCatalogKeyAdaptiveState::from(&current);
if expected.learned_rpm_limit == learned_rpm_limit {
return Ok(Some(current));
}
let mut next = expected.clone();
next.learned_rpm_limit = learned_rpm_limit;
if self
.compare_and_update_provider_catalog_key_adaptive_state(
&ProviderCatalogKeyAdaptiveStateUpdate {
key_id: key_id.to_string(),
expected,
next,
status_snapshot_patch: serde_json::json!({
"learning_confidence": 0.0,
"enforcement_active": false
}),
updated_at_unix_secs,
},
)
.await?
{
return Ok(self
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await?
.into_iter()
.next());
}
}
Err(GatewayError::Internal(format!(
"provider key {key_id} adaptive state changed repeatedly while updating"
)))
}
pub(crate) async fn reset_provider_catalog_key_recovery_state(
&self,
key_id: &str,
) -> Result<
Option<aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey>,
GatewayError,
> {
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyHealthStateUpdate;
let empty = serde_json::json!({});
let mut health_reset = false;
for _ in 0..4 {
let Some(current) = self
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await?
.into_iter()
.next()
else {
return Ok(None);
};
if current.health_by_format.as_ref() == Some(&empty)
&& current.circuit_breaker_by_format.as_ref() == Some(&empty)
{
health_reset = true;
break;
}
if self
.app
.compare_and_update_provider_catalog_key_health_state(
&ProviderCatalogKeyHealthStateUpdate {
key_id: key_id.to_string(),
expected_health_by_format: current.health_by_format,
expected_circuit_breaker_by_format: current.circuit_breaker_by_format,
health_by_format: Some(empty.clone()),
circuit_breaker_by_format: Some(empty.clone()),
},
)
.await?
{
health_reset = true;
break;
}
}
if !health_reset {
return Err(GatewayError::Internal(format!(
"provider key {key_id} health state changed repeatedly while resetting OAuth recovery state"
)));
}
if !self.reset_provider_catalog_key_error_count(key_id).await? {
return Ok(None);
}
Ok(self
.read_provider_catalog_keys_by_ids(&[key_id.to_string()])
.await?
.into_iter()
.next())
}
pub(crate) async fn update_provider_catalog_key_status_snapshot(
&self,
update: &aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyStatusSnapshotUpdate,
) -> Result<bool, GatewayError> {
self.app
.update_provider_catalog_key_status_snapshot(update)
.await
}
pub(crate) async fn update_provider_catalog_keys(
&self,
keys: &[aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey],
@@ -46,6 +46,25 @@ impl<'a> AdminAppState<'a> {
.await
}
pub(crate) async fn update_provider_catalog_key_oauth_runtime_state(
&self,
key_id: &str,
oauth_invalid_at_unix_secs: Option<u64>,
oauth_invalid_reason: Option<&str>,
encrypted_auth_config_update: Option<&str>,
updated_at_unix_secs: Option<u64>,
) -> Result<bool, GatewayError> {
self.app
.update_provider_catalog_key_oauth_runtime_state(
key_id,
oauth_invalid_at_unix_secs,
oauth_invalid_reason,
encrypted_auth_config_update,
updated_at_unix_secs,
)
.await
}
pub(crate) async fn clear_provider_catalog_key_oauth_invalid_marker(
&self,
key_id: &str,
@@ -749,10 +749,11 @@ impl<'a> AdminAppState<'a> {
.collect::<BTreeSet<_>>();
let staged_records = staged_updates
.into_iter()
.map(|(_, updated)| updated)
.iter()
.map(|(_, updated)| updated.clone())
.collect::<Vec<_>>();
let Some(updated_keys) = self.update_provider_catalog_keys(&staged_records).await? else {
let Some(mut updated_keys) = self.update_provider_catalog_keys(&staged_records).await?
else {
return Ok((
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": "Provider 密钥写入能力不可用" })),
@@ -760,6 +761,29 @@ impl<'a> AdminAppState<'a> {
.into_response());
};
for (existing, requested) in &staged_updates {
if requested.learned_rpm_limit == existing.learned_rpm_limit {
continue;
}
let Some(reloaded) = self
.set_provider_catalog_key_learned_rpm_limit(
&requested.id,
requested.learned_rpm_limit,
requested.updated_at_unix_secs,
)
.await?
else {
return Ok((
http::StatusCode::SERVICE_UNAVAILABLE,
Json(json!({ "detail": format!("Provider 密钥 {} 已不存在", requested.id) })),
)
.into_response());
};
if let Some(updated) = updated_keys.iter_mut().find(|key| key.id == requested.id) {
*updated = reloaded;
}
}
let endpoints = self
.list_provider_catalog_endpoints_by_provider_ids(&provider_ids)
.await?;
@@ -7,6 +7,9 @@ use aether_admin::system::{
build_admin_adaptive_set_limit_payload, build_admin_adaptive_stats_payload,
build_admin_adaptive_summary_payload, build_admin_adaptive_toggle_mode_payload,
};
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyAdaptiveState, ProviderCatalogKeyAdaptiveStateUpdate,
};
use axum::{
body::{Body, Bytes},
http,
@@ -130,22 +133,49 @@ impl<'a> AdminAppState<'a> {
&self,
key_id: &str,
) -> Result<Response<Body>, GatewayError> {
let Some(mut key) = self.find_admin_adaptive_key(key_id).await? else {
return Ok(admin_adaptive_key_not_found_response(key_id));
};
key.learned_rpm_limit = None;
key.concurrent_429_count = Some(0);
key.rpm_429_count = Some(0);
key.last_429_at_unix_secs = None;
key.last_429_type = None;
key.adjustment_history = None;
key.utilization_samples = None;
key.last_probe_increase_at_unix_secs = None;
key.last_rpm_peak = None;
let Some(updated) = self.update_provider_catalog_key(&key).await? else {
return Ok(admin_adaptive_key_not_found_response(key_id));
};
Ok(Json(build_admin_adaptive_reset_learning_payload(&updated.id)).into_response())
for _ in 0..4 {
let Some(key) = self.find_admin_adaptive_key(key_id).await? else {
return Ok(admin_adaptive_key_not_found_response(key_id));
};
let expected = ProviderCatalogKeyAdaptiveState::from(&key);
let next = ProviderCatalogKeyAdaptiveState {
learned_rpm_limit: None,
concurrent_429_count: Some(0),
rpm_429_count: Some(0),
last_429_at_unix_secs: None,
last_429_type: None,
adjustment_history: None,
utilization_samples: None,
last_probe_increase_at_unix_secs: None,
last_rpm_peak: None,
};
if self
.compare_and_update_provider_catalog_key_adaptive_state(
&ProviderCatalogKeyAdaptiveStateUpdate {
key_id: key.id.clone(),
expected,
next,
status_snapshot_patch: json!({
"observation_count": 0,
"header_observation_count": 0,
"latest_upstream_limit": null,
"learning_confidence": 0.0,
"enforcement_active": false,
"known_boundary": null
}),
updated_at_unix_secs: None,
},
)
.await?
{
return Ok(
Json(build_admin_adaptive_reset_learning_payload(&key.id)).into_response()
);
}
}
Err(GatewayError::Internal(format!(
"provider key {key_id} adaptive state changed repeatedly while resetting learning"
)))
}
pub(crate) fn admin_adaptive_dispatcher_not_found_response(&self) -> Response<Body> {
@@ -1,7 +1,9 @@
use super::{
AdminAppState, ADMIN_SYSTEM_DATA_EXPORT_VERSION, ADMIN_SYSTEM_DATA_IMPORT_MAX_SIZE_BYTES,
};
use crate::ai_serving::build_provider_key_pool_score_upsert;
use crate::api::ai::admin_endpoint_signature_parts;
use crate::handlers::admin::admin_provider_pool_config;
use crate::handlers::admin::provider::endpoints_admin::payloads::AdminProviderEndpointUpdatePatch;
use crate::handlers::admin::provider::shared::payloads::{
AdminProviderCreateRequest, AdminProviderKeyCreateRequest, AdminProviderKeyUpdatePatch,
@@ -47,6 +49,7 @@ use aether_data_contracts::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery, CreateAdminGlobalModelRecord,
UpdateAdminGlobalModelRecord, UpsertAdminProviderModelRecord,
};
use aether_data_contracts::repository::pool_scores::PoolMemberScoreUpsertMode;
use axum::{body::Bytes, http};
use serde_json::{json, Map, Value};
use std::collections::{BTreeMap, BTreeSet};
@@ -362,18 +365,24 @@ fn apply_imported_oauth_key_credentials(
raw_key: &Map<String, Value>,
normalized_auth_config: Option<&Value>,
record: &mut aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
) -> Result<(), String> {
) -> Result<bool, String> {
let mut credentials_supplied = false;
let mut api_key_supplied = false;
if let Some(api_key_value) = raw_key.get("api_key") {
let plaintext = api_key_value
.as_str()
.map(str::trim)
.filter(|value| !value.is_empty());
record.encrypted_api_key = match plaintext {
Some(plaintext) => Some(
state
.encrypt_catalog_secret_with_fallbacks(plaintext)
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?,
),
Some(plaintext) => {
credentials_supplied = true;
api_key_supplied = true;
Some(
state
.encrypt_catalog_secret_with_fallbacks(plaintext)
.ok_or_else(|| "gateway 未配置 provider key 加密密钥".to_string())?,
)
}
None => None,
};
}
@@ -381,6 +390,7 @@ fn apply_imported_oauth_key_credentials(
if raw_key.contains_key("auth_config") {
record.encrypted_auth_config = match normalized_auth_config {
Some(auth_config) => {
credentials_supplied |= imported_oauth_auth_config_has_credentials(auth_config);
let plaintext =
serde_json::to_string(auth_config).map_err(|err| err.to_string())?;
Some(
@@ -392,14 +402,66 @@ fn apply_imported_oauth_key_credentials(
None => None,
};
}
record.expires_at_unix_secs = imported_oauth_expiry_after_import(
record.expires_at_unix_secs,
raw_key.contains_key("auth_config"),
normalized_auth_config,
api_key_supplied,
);
// Importing OAuth credentials replaces the previous session state, so stale
// expiry/invalid markers must not survive across the overwrite.
record.expires_at_unix_secs = imported_oauth_expires_at_unix_secs(normalized_auth_config);
record.oauth_invalid_at_unix_secs = None;
record.oauth_invalid_reason = None;
if credentials_supplied {
record.oauth_invalid_at_unix_secs = None;
record.oauth_invalid_reason = None;
}
Ok(())
Ok(credentials_supplied)
}
fn imported_oauth_auth_config_has_credentials(value: &Value) -> bool {
const CREDENTIAL_FIELDS: &[&str] = &[
"access_token",
"accessToken",
"api_key",
"apiKey",
"auth_token",
"authToken",
"cf_clearance",
"cfClearance",
"cf_cookies",
"cfCookies",
"cookie",
"cookieHeader",
"cookies",
"id_token",
"idToken",
"refresh_token",
"refreshToken",
"session_token",
"sessionToken",
"sso_rw_token",
"ssoRwToken",
"sso_token",
"ssoToken",
"token",
];
match value {
Value::Object(object) => object.iter().any(|(key, value)| {
(CREDENTIAL_FIELDS.contains(&key.as_str()) && imported_credential_value_present(value))
|| imported_oauth_auth_config_has_credentials(value)
}),
Value::Array(items) => items.iter().any(imported_oauth_auth_config_has_credentials),
_ => false,
}
}
fn imported_credential_value_present(value: &Value) -> bool {
match value {
Value::String(value) => !value.trim().is_empty(),
Value::Array(items) => !items.is_empty(),
Value::Object(object) => !object.is_empty(),
_ => false,
}
}
fn imported_oauth_expires_at_unix_secs(normalized_auth_config: Option<&Value>) -> Option<u64> {
@@ -425,6 +487,63 @@ fn imported_oauth_expires_at_unix_secs(normalized_auth_config: Option<&Value>) -
None
}
fn imported_oauth_expiry_after_import(
current: Option<u64>,
auth_config_present: bool,
normalized_auth_config: Option<&Value>,
api_key_supplied: bool,
) -> Option<u64> {
if auth_config_present {
imported_oauth_expires_at_unix_secs(normalized_auth_config)
} else if api_key_supplied {
None
} else {
current
}
}
async fn seed_imported_oauth_pool_score(
state: &AdminAppState<'_>,
provider_id: &str,
key: &aether_data_contracts::repository::provider_catalog::StoredProviderCatalogKey,
now_unix_secs: u64,
) -> Result<(), GatewayError> {
let provider_id = provider_id.to_string();
let provider = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&provider_id))
.await?
.pop();
let Some(provider) = provider else {
return Ok(());
};
let Some(pool_config) = admin_provider_pool_config(&provider) else {
return Ok(());
};
if !key.is_active || key.provider_id != provider.id {
return Ok(());
}
let upsert = build_provider_key_pool_score_upsert(
key,
provider.provider_type.as_str(),
None,
now_unix_secs,
pool_config.score_rules,
);
state
.app()
.data
.upsert_pool_member_score_with_mode(upsert, PoolMemberScoreUpsertMode::OAuthRecovery)
.await
.map_err(|error| {
GatewayError::Internal(format!(
"failed to recover OAuth pool score for key '{}': {error}",
key.id
))
})?;
Ok(())
}
fn build_import_provider_model_record(
provider_id: &str,
existing_id: Option<&str>,
@@ -1528,6 +1647,11 @@ impl<'a> AdminAppState<'a> {
.keys()
.cloned()
.collect::<BTreeSet<_>>();
let now_unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.ok()
.map(|duration| duration.as_secs())
.unwrap_or(0);
let imported_keys = routed!(parse_admin_system_config_nested_array::<
ImportedProviderKey,
@@ -1638,27 +1762,74 @@ impl<'a> AdminAppState<'a> {
)
.await
);
if auth_type == "oauth" {
let oauth_credentials_supplied = if auth_type == "oauth" {
invalid!(apply_imported_oauth_key_credentials(
self,
&raw_key,
normalized_auth_config.as_ref(),
&mut updated,
));
}
))
} else {
false
};
updated.proxy =
remap_import_proxy(imported_key.proxy.clone(), &node_id_map);
updated.fingerprint = invalid!(normalize_json_object(
imported_key.fingerprint.clone(),
"fingerprint",
));
let Some(persisted) =
let Some(mut persisted) =
self.update_provider_catalog_key(&updated).await?
else {
return Ok(Err(invalid_request(format!(
"更新 Provider '{provider_name}' 的 Key 失败"
))));
};
if updated.learned_rpm_limit != existing_key.learned_rpm_limit {
let Some(reloaded) = self
.set_provider_catalog_key_learned_rpm_limit(
&updated.id,
updated.learned_rpm_limit,
updated.updated_at_unix_secs,
)
.await?
else {
return Ok(Err(invalid_request(format!(
"更新 Provider '{provider_name}' 的 Key 失败"
))));
};
persisted = reloaded;
}
if oauth_credentials_supplied {
if !self
.clear_provider_catalog_key_oauth_invalid_marker(&updated.id)
.await?
{
return Ok(Err(invalid_request(format!(
"更新 Provider '{provider_name}' 的 Key 失败"
))));
}
let Some(reloaded) = self
.reset_provider_catalog_key_recovery_state(&updated.id)
.await?
else {
return Ok(Err(invalid_request(format!(
"更新 Provider '{provider_name}' 的 Key 失败"
))));
};
persisted = reloaded;
let _ = self
.app()
.invalidate_local_oauth_refresh_entry(&updated.id)
.await;
seed_imported_oauth_pool_score(
self,
&provider.id,
&persisted,
now_unix_secs,
)
.await?;
}
existing_keys[existing_index] = persisted;
stats.keys.updated += 1;
}
@@ -1676,14 +1847,16 @@ impl<'a> AdminAppState<'a> {
self.build_admin_create_provider_key_record(&provider, payload)
.await
);
if auth_type == "oauth" {
let oauth_credentials_supplied = if auth_type == "oauth" {
invalid!(apply_imported_oauth_key_credentials(
self,
&raw_key,
normalized_auth_config.as_ref(),
&mut record,
));
}
))
} else {
false
};
record.is_active = imported_key.is_active;
record.global_priority_by_format = invalid!(normalize_json_object(
imported_key.global_priority_by_format.clone(),
@@ -1699,6 +1872,10 @@ impl<'a> AdminAppState<'a> {
"创建 Provider '{provider_name}' 的 Key 失败"
))));
};
if oauth_credentials_supplied {
seed_imported_oauth_pool_score(self, &provider.id, &created, now_unix_secs)
.await?;
}
existing_keys.push(created);
stats.keys.created += 1;
}
@@ -3283,16 +3460,27 @@ enum WalletOwner<'a> {
#[cfg(test)]
mod tests {
use std::sync::Arc;
use aether_data::repository::pool_scores::SqlitePoolMemberScoreRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use super::{
build_imported_user_usage_total_aggregates, imported_optional_bool, imported_optional_f64,
build_imported_user_usage_total_aggregates, imported_oauth_auth_config_has_credentials,
imported_oauth_expiry_after_import, imported_optional_bool, imported_optional_f64,
imported_optional_i32, imported_optional_u64, imported_rfc3339_to_unix_secs,
imported_string_list_from_value, normalize_import_endpoint_format,
normalize_import_key_formats, normalize_import_key_raw_payload,
normalize_imported_wallet_target, validate_imported_system_users_export_version,
ImportedProviderKey,
normalize_imported_wallet_target, seed_imported_oauth_pool_score,
validate_imported_system_users_export_version, ImportedProviderKey,
};
use crate::admin_api::AdminAppState;
use crate::data::GatewayDataState;
use crate::AppState;
#[test]
fn users_import_requires_supported_export_version() {
@@ -3458,6 +3646,123 @@ mod tests {
assert_eq!(payload["allow_auth_channel_mismatch_formats"], json!([]));
}
#[test]
fn oauth_import_only_treats_non_empty_secret_fields_as_credentials() {
assert!(!imported_oauth_auth_config_has_credentials(&json!({})));
assert!(!imported_oauth_auth_config_has_credentials(&json!({
"provider_type": "codex",
"expires_at": 4_102_444_800u64,
"account_id": "acct-1",
"refresh_token": " "
})));
assert!(imported_oauth_auth_config_has_credentials(&json!({
"provider_type": "codex",
"refresh_token": "refresh-1"
})));
assert!(imported_oauth_auth_config_has_credentials(&json!({
"session": {"sso_token": "sso-1"}
})));
for field in [
"sso_rw_token",
"ssoRwToken",
"cf_cookies",
"cfCookies",
"cf_clearance",
"cfClearance",
"cookieHeader",
] {
let mut config = serde_json::Map::new();
config.insert(field.to_string(), json!("credential-1"));
assert!(
imported_oauth_auth_config_has_credentials(&serde_json::Value::Object(config)),
"{field} is transport credential material"
);
}
}
#[test]
fn oauth_import_expiry_tracks_the_supplied_credential_source() {
let old_expiry = Some(1_700_000_000);
assert_eq!(
imported_oauth_expiry_after_import(old_expiry, false, None, true),
None,
"a new top-level api_key replaces the old session and clears its expiry"
);
assert_eq!(
imported_oauth_expiry_after_import(old_expiry, false, None, false),
old_expiry,
"metadata-only imports preserve the current OAuth expiry"
);
assert_eq!(
imported_oauth_expiry_after_import(
old_expiry,
true,
Some(&json!({"expires_at": 4_102_444_800u64})),
false,
),
Some(4_102_444_800),
"an explicit auth_config owns the replacement expiry"
);
}
#[tokio::test]
async fn oauth_pool_score_persistence_failure_is_propagated() {
let mut provider = StoredProviderCatalogProvider::new(
"provider-1".to_string(),
"Provider One".to_string(),
None,
"codex".to_string(),
)
.expect("provider should build");
provider.config = Some(json!({"pool_advanced": {}}));
let key = StoredProviderCatalogKey::new(
"key-1".to_string(),
provider.id.clone(),
"OAuth Key".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build");
let provider_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
vec![key.clone()],
));
let no_writer_app = AppState::new()
.expect("app state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(Arc::clone(
&provider_repository,
)),
);
seed_imported_oauth_pool_score(&AdminAppState::new(&no_writer_app), "provider-1", &key, 99)
.await
.expect("a disabled score writer remains an allowed no-op");
let pool = sqlx::sqlite::SqlitePoolOptions::new()
.max_connections(1)
.connect("sqlite::memory:")
.await
.expect("sqlite pool should connect");
let score_repository = Arc::new(SqlitePoolMemberScoreRepository::new(pool.clone()));
pool.close().await;
let app = AppState::new()
.expect("app state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(provider_repository)
.with_pool_score_repository_for_tests(score_repository),
);
let error =
seed_imported_oauth_pool_score(&AdminAppState::new(&app), "provider-1", &key, 100)
.await
.expect_err("closed pool must fail OAuth score recovery");
assert!(error
.into_message()
.contains("failed to recover OAuth pool score for key 'key-1'"));
}
#[test]
fn import_handles_legacy_string_scalars() {
assert_eq!(
@@ -536,8 +536,7 @@ pub(crate) async fn build_admin_system_stats_payload(
let now_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
let usage_counter_snapshot = state
.as_ref()
.data
.read_usage_counter_health()
.read_cached_usage_counter_health()
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
let usage_counter =
+144 -28
View File
@@ -377,6 +377,7 @@ fn usage_database_config_for_role<'a>(
fn usage_queue_worker_database_cap(
node_role: NodeRoleArg,
database: Option<&SqlDatabaseConfig>,
database_is_isolated: bool,
) -> usize {
let Some(database) = database else {
return MAX_USAGE_QUEUE_WORKERS;
@@ -385,12 +386,20 @@ fn usage_queue_worker_database_cap(
return 1;
}
let max_connections = database.pool.max_connections.max(1) as usize;
// An isolated pool is already a dedicated background budget. Applying the shared-pool
// divisor a second time would underutilize that pool.
if database_is_isolated {
return max_connections
.saturating_sub(1)
.max(1)
.clamp(1, MAX_USAGE_QUEUE_WORKERS);
}
let divisor = if matches!(node_role, NodeRoleArg::Background) {
AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_BACKGROUND
} else {
AUTO_USAGE_QUEUE_WORKERS_DB_SHARE_ALL
};
let max_connections = database.pool.max_connections.max(1) as usize;
max_connections
.saturating_add(divisor - 1)
.checked_div(divisor)
@@ -401,18 +410,29 @@ fn usage_queue_worker_database_cap(
fn usage_worker_record_concurrency_database_cap(
node_role: NodeRoleArg,
database: Option<&SqlDatabaseConfig>,
database_is_isolated: bool,
) -> Option<usize> {
let database = database?;
if database.driver == DatabaseDriver::Sqlite {
return Some(1);
}
let max_connections = database.pool.max_connections.max(1) as usize;
// The isolated background pool has already been carved out of the foreground pool. Keep one
// connection available for maintenance/health work and use the rest for usage persistence.
if database_is_isolated {
return Some(
max_connections
.saturating_sub(1)
.max(1)
.clamp(1, MAX_USAGE_QUEUE_WORKERS),
);
}
let divisor = if matches!(node_role, NodeRoleArg::Background) {
AUTO_USAGE_WORKER_RECORD_DB_SHARE_BACKGROUND
} else {
AUTO_USAGE_WORKER_RECORD_DB_SHARE_ALL
};
let max_connections = database.pool.max_connections.max(1) as usize;
Some(
max_connections
.checked_div(divisor.max(1))
@@ -427,6 +447,7 @@ fn automatic_usage_queue_workers_for_parallelism(
max_in_flight_requests: Option<usize>,
distributed_request_limit: Option<usize>,
database: Option<&SqlDatabaseConfig>,
database_is_isolated: bool,
) -> usize {
let cpu_default = parallelism.max(1).clamp(
AUTO_USAGE_QUEUE_WORKERS_MIN,
@@ -437,7 +458,11 @@ fn automatic_usage_queue_workers_for_parallelism(
.map(usage_queue_workers_for_request_concurrency)
.unwrap_or(cpu_default);
requested
.min(usage_queue_worker_database_cap(node_role, database))
.min(usage_queue_worker_database_cap(
node_role,
database,
database_is_isolated,
))
.clamp(1, MAX_USAGE_QUEUE_WORKERS)
}
@@ -446,6 +471,7 @@ fn automatic_usage_queue_workers(
max_in_flight_requests: Option<usize>,
distributed_request_limit: Option<usize>,
database: Option<&SqlDatabaseConfig>,
database_is_isolated: bool,
) -> usize {
automatic_usage_queue_workers_for_parallelism(
available_parallelism_usize(),
@@ -453,6 +479,7 @@ fn automatic_usage_queue_workers(
max_in_flight_requests,
distributed_request_limit,
database,
database_is_isolated,
)
}
@@ -888,6 +915,7 @@ impl GatewayUsageArgs {
max_in_flight_requests: Option<usize>,
distributed_request_limit: Option<usize>,
database: Option<&SqlDatabaseConfig>,
database_is_isolated: bool,
) -> usize {
if let Some(queue_workers) = self.queue_workers {
return queue_workers.clamp(1, MAX_USAGE_QUEUE_WORKERS);
@@ -900,6 +928,7 @@ impl GatewayUsageArgs {
max_in_flight_requests,
distributed_request_limit,
database,
database_is_isolated,
)
}
@@ -908,14 +937,21 @@ impl GatewayUsageArgs {
node_role: NodeRoleArg,
database: Option<&SqlDatabaseConfig>,
worker_count: usize,
database_is_isolated: bool,
) -> usize {
if !self.queue_worker_autoscale_enabled {
return worker_count.clamp(1, MAX_USAGE_QUEUE_WORKERS);
}
self.queue_worker_max_count
.unwrap_or_else(|| usage_queue_worker_database_cap(node_role, database))
.unwrap_or_else(|| {
usage_queue_worker_database_cap(node_role, database, database_is_isolated)
})
.max(1)
.min(usage_queue_worker_database_cap(node_role, database))
.min(usage_queue_worker_database_cap(
node_role,
database,
database_is_isolated,
))
.clamp(worker_count.max(1), MAX_USAGE_QUEUE_WORKERS)
}
@@ -938,6 +974,7 @@ impl GatewayUsageArgs {
&self,
node_role: NodeRoleArg,
database: Option<&SqlDatabaseConfig>,
database_is_isolated: bool,
) -> Option<usize> {
if let Some(limit) = self.worker_record_concurrency_limit {
if limit == 0 {
@@ -947,8 +984,12 @@ impl GatewayUsageArgs {
limit
.min(MAX_USAGE_QUEUE_WORKERS)
.min(
usage_worker_record_concurrency_database_cap(node_role, database)
.unwrap_or(MAX_USAGE_QUEUE_WORKERS),
usage_worker_record_concurrency_database_cap(
node_role,
database,
database_is_isolated,
)
.unwrap_or(MAX_USAGE_QUEUE_WORKERS),
)
.max(1),
);
@@ -958,7 +999,7 @@ impl GatewayUsageArgs {
{
return None;
}
usage_worker_record_concurrency_database_cap(node_role, database)
usage_worker_record_concurrency_database_cap(node_role, database, database_is_isolated)
}
fn to_config(
@@ -1741,6 +1782,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
} else {
None
};
let usage_database_is_isolated =
isolate_background_database && background_database_config.is_some();
let usage_database_config = usage_database_config_for_role(
args.node_role,
data_config.database(),
@@ -1765,15 +1808,20 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
Some(request_concurrency_limit),
args.distributed_request_limit,
usage_database_config,
usage_database_is_isolated,
);
let usage_queue_worker_max_count = args.usage.effective_queue_worker_max_count(
args.node_role,
usage_database_config,
usage_queue_workers,
usage_database_is_isolated,
);
let usage_worker_record_concurrency_limit = args
.usage
.effective_worker_record_concurrency_limit(args.node_role, usage_database_config);
let usage_worker_record_concurrency_limit =
args.usage.effective_worker_record_concurrency_limit(
args.node_role,
usage_database_config,
usage_database_is_isolated,
);
let usage_config = args.usage.to_config(
usage_queue_workers,
usage_queue_worker_max_count,
@@ -2780,6 +2828,7 @@ mod tests {
Some(10_000),
None,
Some(&database),
false,
);
assert_eq!(workers, 64);
@@ -2796,6 +2845,7 @@ mod tests {
None,
None,
Some(&database),
false,
);
assert_eq!(workers, 4);
@@ -2807,12 +2857,19 @@ mod tests {
args.usage.queue_workers = None;
let database = test_database(DatabaseDriver::Postgres, 40);
let workers =
args.usage
.effective_queue_workers(args.node_role, Some(1_024), None, Some(&database));
let max_workers =
args.usage
.effective_queue_worker_max_count(args.node_role, Some(&database), workers);
let workers = args.usage.effective_queue_workers(
args.node_role,
Some(1_024),
None,
Some(&database),
false,
);
let max_workers = args.usage.effective_queue_worker_max_count(
args.node_role,
Some(&database),
workers,
false,
);
assert_eq!(workers, 8);
assert_eq!(max_workers, 10);
@@ -2825,12 +2882,19 @@ mod tests {
args.usage.queue_worker_max_count = Some(32);
let database = test_database(DatabaseDriver::Postgres, 200);
let workers =
args.usage
.effective_queue_workers(args.node_role, Some(1_024), None, Some(&database));
let max_workers =
args.usage
.effective_queue_worker_max_count(args.node_role, Some(&database), workers);
let workers = args.usage.effective_queue_workers(
args.node_role,
Some(1_024),
None,
Some(&database),
false,
);
let max_workers = args.usage.effective_queue_worker_max_count(
args.node_role,
Some(&database),
workers,
false,
);
assert_eq!(workers, 8);
assert_eq!(max_workers, 32);
@@ -2842,19 +2906,63 @@ mod tests {
let database = test_database(DatabaseDriver::Postgres, 64);
assert_eq!(
args.usage
.effective_worker_record_concurrency_limit(NodeRoleArg::All, Some(&database)),
args.usage.effective_worker_record_concurrency_limit(
NodeRoleArg::All,
Some(&database),
false,
),
Some(8)
);
assert_eq!(
args.usage.effective_worker_record_concurrency_limit(
NodeRoleArg::Background,
Some(&database)
Some(&database),
false,
),
Some(16)
);
}
#[test]
fn gateway_usage_isolated_database_uses_dedicated_capacity_once() {
let mut args = test_args();
args.usage.queue_workers = None;
let database = test_database(DatabaseDriver::Postgres, 64);
let isolated_background = test_database(DatabaseDriver::Postgres, 8);
let usage_database = usage_database_config_for_role(
NodeRoleArg::All,
Some(&database),
Some(&isolated_background),
)
.expect("isolated usage database");
let workers = args.usage.effective_queue_workers(
NodeRoleArg::All,
Some(5_000),
None,
Some(usage_database),
true,
);
let max_workers = args.usage.effective_queue_worker_max_count(
NodeRoleArg::All,
Some(usage_database),
workers,
true,
);
assert_eq!(usage_database.pool.max_connections, 8);
assert_eq!(workers, 7);
assert_eq!(max_workers, 7);
assert_eq!(
args.usage.effective_worker_record_concurrency_limit(
NodeRoleArg::All,
Some(usage_database),
true,
),
Some(7)
);
}
#[test]
fn gateway_usage_worker_record_concurrency_can_be_explicitly_disabled() {
let mut args = test_args();
@@ -2862,8 +2970,11 @@ mod tests {
let database = test_database(DatabaseDriver::Postgres, 64);
assert_eq!(
args.usage
.effective_worker_record_concurrency_limit(NodeRoleArg::All, Some(&database)),
args.usage.effective_worker_record_concurrency_limit(
NodeRoleArg::All,
Some(&database),
false,
),
None
);
}
@@ -2913,6 +3024,7 @@ mod tests {
Some(1_536),
None,
Some(&database),
false,
);
assert_eq!(workers, 12);
@@ -2928,6 +3040,7 @@ mod tests {
Some(2_048),
Some(256),
Some(&database),
false,
);
assert_eq!(workers, 2);
@@ -2943,6 +3056,7 @@ mod tests {
Some(5_000),
None,
Some(&database),
false,
);
assert_eq!(workers, 5);
@@ -2958,6 +3072,7 @@ mod tests {
Some(5_000),
None,
Some(&database),
false,
);
assert_eq!(workers, 10);
@@ -2973,6 +3088,7 @@ mod tests {
Some(5_000),
None,
Some(&database),
false,
);
assert_eq!(workers, 1);
+637 -196
View File
@@ -1,5 +1,6 @@
use std::collections::BTreeMap;
use std::sync::LazyLock;
use std::collections::{BTreeMap, HashMap};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex as StdMutex, Weak};
use std::time::Duration;
use aether_admin::provider::quota as admin_provider_quota_pure;
@@ -8,6 +9,10 @@ use aether_contracts::{ExecutionPlan, ExecutionTelemetry};
use aether_data_contracts::repository::pool_scores::{
PoolMemberHardState, PoolMemberIdentity, PoolMemberScheduleFeedback,
};
use aether_data_contracts::repository::provider_catalog::{
ProviderCatalogKeyAdaptiveState, ProviderCatalogKeyAdaptiveStateUpdate,
ProviderCatalogKeyHealthStateUpdate,
};
use aether_scheduler_core::{
build_scheduler_affinity_cache_key_for_api_key_id_with_client_session,
count_recent_rpm_requests_for_provider_key, ClientSessionAffinity, SchedulerAffinityTarget,
@@ -17,6 +22,7 @@ use aether_usage_runtime::{
GatewayStreamReportRequest, GatewaySyncReportRequest, TerminalUsageOutcome,
};
use serde_json::Value;
use tokio::sync::Mutex as TokioMutex;
use tracing::warn;
use super::{
@@ -44,21 +50,116 @@ use crate::{
const POOL_SCORE_FEEDBACK_GATE_MAX_ENTRIES: usize = 50_000;
const HEALTH_SUCCESS_PERSIST_GATE_MAX_ENTRIES: usize = 50_000;
const ADAPTIVE_SUCCESS_PERSIST_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 HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_ENV: &str =
"AETHER_GATEWAY_PROVIDER_KEY_HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_SECS";
const ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_ENV: &str =
"AETHER_GATEWAY_PROVIDER_KEY_ADAPTIVE_SUCCESS_PERSIST_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 DEFAULT_HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_SECS: u64 = 5;
const DEFAULT_ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_SECS: u64 = 5;
const MAX_POOL_SCORE_FEEDBACK_MIN_INTERVAL_SECS: u64 = 300;
const PROVIDER_KEY_EFFECT_LOCK_PRUNE_THRESHOLD: usize = 8_192;
// Same-process writers are serialized by the per-key lock. Keep remote-writer
// retries bounded so request/report completion cannot accumulate a long DB tail.
const PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS: usize = 4;
#[derive(Debug)]
struct ProviderKeyEffectLockPoolState {
entries: HashMap<String, Weak<TokioMutex<()>>>,
accesses_since_prune: usize,
next_growth_prune_at: usize,
#[cfg(test)]
prune_count: usize,
}
impl ProviderKeyEffectLockPoolState {
fn new(min_prune_threshold: usize) -> Self {
Self {
entries: HashMap::new(),
accesses_since_prune: 0,
next_growth_prune_at: min_prune_threshold,
#[cfg(test)]
prune_count: 0,
}
}
}
#[derive(Debug)]
struct ProviderKeyEffectLockPool {
state: StdMutex<ProviderKeyEffectLockPoolState>,
min_prune_threshold: usize,
}
impl Default for ProviderKeyEffectLockPool {
fn default() -> Self {
Self::new(PROVIDER_KEY_EFFECT_LOCK_PRUNE_THRESHOLD)
}
}
impl ProviderKeyEffectLockPool {
fn new(min_prune_threshold: usize) -> Self {
let min_prune_threshold = min_prune_threshold.max(1);
Self {
state: StdMutex::new(ProviderKeyEffectLockPoolState::new(min_prune_threshold)),
min_prune_threshold,
}
}
fn lock_for(&self, key_id: &str) -> Arc<TokioMutex<()>> {
let mut state = self
.state
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.accesses_since_prune = state.accesses_since_prune.saturating_add(1);
let entry_count = state.entries.len();
let growth_prune_due = entry_count >= state.next_growth_prune_at;
let maintenance_prune_due = entry_count >= self.min_prune_threshold
&& state.accesses_since_prune >= entry_count.max(self.min_prune_threshold);
if growth_prune_due || maintenance_prune_due {
self.prune_inactive_locks(&mut state);
}
if let Some(existing) = state.entries.get(key_id).and_then(Weak::upgrade) {
return existing;
}
let lock = Arc::new(TokioMutex::new(()));
state
.entries
.insert(key_id.to_string(), Arc::downgrade(&lock));
lock
}
fn prune_inactive_locks(&self, state: &mut ProviderKeyEffectLockPoolState) {
state.entries.retain(|_, lock| lock.strong_count() > 0);
let active_entries = state.entries.len();
state.next_growth_prune_at = if active_entries < self.min_prune_threshold {
self.min_prune_threshold
} else {
active_entries.saturating_mul(2)
};
state.accesses_since_prune = 0;
#[cfg(test)]
{
state.prune_count = state.prune_count.saturating_add(1);
}
}
}
static POOL_SCORE_FEEDBACK_GATE: LazyLock<ExpiringMap<String, ()>> =
LazyLock::new(ExpiringMap::new);
static HEALTH_SUCCESS_PERSIST_GATE: LazyLock<ExpiringMap<String, ()>> =
LazyLock::new(ExpiringMap::new);
static ADAPTIVE_SUCCESS_PERSIST_GATE: LazyLock<ExpiringMap<String, u64>> =
LazyLock::new(ExpiringMap::new);
static ADAPTIVE_SUCCESS_PERSIST_GATE_NEXT_TOKEN: AtomicU64 = AtomicU64::new(1);
static PROVIDER_KEY_EFFECT_LOCKS: LazyLock<ProviderKeyEffectLockPool> =
LazyLock::new(ProviderKeyEffectLockPool::default);
static POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
pool_score_feedback_interval_from_env(
POOL_SCORE_SUCCESS_FEEDBACK_MIN_INTERVAL_ENV,
@@ -77,6 +178,12 @@ static HEALTH_SUCCESS_PERSIST_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|
DEFAULT_HEALTH_SUCCESS_PERSIST_MIN_INTERVAL_SECS,
)
});
static ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL: LazyLock<Duration> = LazyLock::new(|| {
pool_score_feedback_interval_from_env(
ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_ENV,
DEFAULT_ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL_SECS,
)
});
#[derive(Debug, Clone, Copy)]
pub(crate) struct LocalExecutionEffectContext<'a> {
@@ -495,15 +602,9 @@ async fn record_adaptive_rate_limit_effect(
context: LocalExecutionEffectContext<'_>,
effect: LocalAdaptiveRateLimitEffect<'_>,
) {
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
let _effect_guard = effect_lock.lock().await;
let observed_at_unix_secs = current_unix_secs();
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
let current_rpm = state
.read_recent_request_candidates(ADAPTIVE_RPM_RECENT_CANDIDATE_LIMIT)
.await
@@ -515,38 +616,88 @@ async fn record_adaptive_rate_limit_effect(
observed_at_unix_secs,
) as u32
});
let Some(projection) = project_local_adaptive_rate_limit(
&current_key,
effect.classification,
effect.status_code,
current_rpm,
effect.headers,
observed_at_unix_secs,
) else {
return;
};
let mut updated_key = current_key.clone();
updated_key.rpm_429_count = Some(projection.rpm_429_count);
updated_key.learned_rpm_limit = projection.learned_rpm_limit;
updated_key.last_429_at_unix_secs = Some(projection.last_429_at_unix_secs);
updated_key.last_429_type = Some(projection.last_429_type);
updated_key.adjustment_history = projection.adjustment_history;
updated_key.utilization_samples = projection.utilization_samples;
updated_key.last_probe_increase_at_unix_secs = projection.last_probe_increase_at_unix_secs;
updated_key.last_rpm_peak = projection.last_rpm_peak;
updated_key.status_snapshot = Some(projection.status_snapshot);
updated_key.updated_at_unix_secs = Some(observed_at_unix_secs);
if let Err(err) = state
.update_provider_catalog_key_runtime_state(&updated_key)
.await
{
warn!(
"gateway orchestration effects: failed to persist adaptive rate-limit projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
let Some(projection) = project_local_adaptive_rate_limit(
&current_key,
effect.classification,
effect.status_code,
current_rpm,
effect.headers,
observed_at_unix_secs,
) else {
return;
};
let expected = ProviderCatalogKeyAdaptiveState::from(&current_key);
let mut next = expected.clone();
next.rpm_429_count = Some(projection.rpm_429_count);
next.learned_rpm_limit = projection.learned_rpm_limit;
next.last_429_at_unix_secs = Some(projection.last_429_at_unix_secs);
next.last_429_type = Some(projection.last_429_type);
next.adjustment_history = projection.adjustment_history;
next.utilization_samples = projection.utilization_samples;
next.last_probe_increase_at_unix_secs = projection.last_probe_increase_at_unix_secs;
next.last_rpm_peak = projection.last_rpm_peak;
let update = ProviderCatalogKeyAdaptiveStateUpdate {
key_id: context.plan.key_id.clone(),
expected,
next,
status_snapshot_patch: adaptive_status_snapshot_patch(&projection.status_snapshot),
updated_at_unix_secs: Some(observed_at_unix_secs),
};
provider_key_adaptive_success_persist_gate_reset(&context.plan.key_id);
match state
.compare_and_update_provider_catalog_key_adaptive_state(&update)
.await
{
Ok(true) => return,
Ok(false) => tokio::task::yield_now().await,
Err(err) => {
warn!(
"gateway orchestration effects: failed to persist adaptive rate-limit projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
return;
}
}
}
warn!(
"gateway orchestration effects: adaptive rate-limit CAS retries exhausted for provider {} endpoint {} key {}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
);
}
fn adaptive_status_snapshot_patch(status_snapshot: &Value) -> Value {
const OWNED_FIELDS: [&str; 6] = [
"observation_count",
"header_observation_count",
"latest_upstream_limit",
"learning_confidence",
"enforcement_active",
"known_boundary",
];
let Some(snapshot) = status_snapshot.as_object() else {
return serde_json::json!({});
};
Value::Object(
OWNED_FIELDS
.into_iter()
.filter_map(|field| {
snapshot
.get(field)
.cloned()
.map(|value| (field.to_string(), value))
})
.collect(),
)
}
async fn record_adaptive_success_effect(
@@ -571,6 +722,19 @@ async fn record_adaptive_success_effect(
{
return;
}
let Some(gate_token) = provider_key_adaptive_success_persist_gate_admit(&context.plan.key_id)
else {
return;
};
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
let _effect_guard = effect_lock.lock().await;
if !provider_key_adaptive_success_persist_gate_admission_is_current(
&context.plan.key_id,
gate_token,
) {
return;
}
let Some(recent_candidates) = state
.read_recent_request_candidates(ADAPTIVE_RPM_RECENT_CANDIDATE_LIMIT)
.await
@@ -583,29 +747,101 @@ async fn record_adaptive_success_effect(
&context.plan.key_id,
observed_at_unix_secs,
) as u32;
let Some(projection) =
project_local_adaptive_success(&current_key, current_rpm, observed_at_unix_secs)
else {
return;
};
let mut updated_key = current_key.clone();
updated_key.learned_rpm_limit = projection.learned_rpm_limit;
updated_key.adjustment_history = projection.adjustment_history;
updated_key.utilization_samples = projection.utilization_samples;
updated_key.last_probe_increase_at_unix_secs = projection.last_probe_increase_at_unix_secs;
updated_key.status_snapshot = Some(projection.status_snapshot);
updated_key.updated_at_unix_secs = Some(observed_at_unix_secs);
if let Err(err) = state
.update_provider_catalog_key_runtime_state(&updated_key)
.await
{
warn!(
"gateway orchestration effects: failed to persist adaptive success projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
if current_key.rpm_limit.is_some()
|| current_key
.learned_rpm_limit
.filter(|value| *value > 0)
.is_none()
{
return;
}
let Some(projection) =
project_local_adaptive_success(&current_key, current_rpm, observed_at_unix_secs)
else {
return;
};
let expected = ProviderCatalogKeyAdaptiveState::from(&current_key);
let mut next = expected.clone();
next.learned_rpm_limit = projection.learned_rpm_limit;
next.adjustment_history = projection.adjustment_history;
next.utilization_samples = projection.utilization_samples;
next.last_probe_increase_at_unix_secs = projection.last_probe_increase_at_unix_secs;
let update = ProviderCatalogKeyAdaptiveStateUpdate {
key_id: context.plan.key_id.clone(),
expected,
next,
status_snapshot_patch: adaptive_status_snapshot_patch(&projection.status_snapshot),
updated_at_unix_secs: Some(observed_at_unix_secs),
};
match state
.compare_and_update_provider_catalog_key_adaptive_state(&update)
.await
{
Ok(true) => return,
Ok(false) => tokio::task::yield_now().await,
Err(err) => {
warn!(
"gateway orchestration effects: failed to persist adaptive success projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
return;
}
}
}
warn!(
"gateway orchestration effects: adaptive success CAS retries exhausted for provider {} endpoint {} key {}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
);
}
fn provider_key_adaptive_success_persist_gate_admit(key_id: &str) -> Option<u64> {
if cfg!(test) {
return Some(0);
}
let interval = *ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL;
if interval.is_zero() {
return Some(0);
}
let token = ADAPTIVE_SUCCESS_PERSIST_GATE_NEXT_TOKEN.fetch_add(1, Ordering::Relaxed);
ADAPTIVE_SUCCESS_PERSIST_GATE
.insert_if_absent_fresh(
provider_key_adaptive_success_persist_gate_key(key_id),
token,
interval,
ADAPTIVE_SUCCESS_PERSIST_GATE_MAX_ENTRIES,
)
.then_some(token)
}
fn provider_key_adaptive_success_persist_gate_admission_is_current(
key_id: &str,
token: u64,
) -> bool {
if cfg!(test) || ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL.is_zero() {
return true;
}
ADAPTIVE_SUCCESS_PERSIST_GATE.get_fresh(
&provider_key_adaptive_success_persist_gate_key(key_id),
*ADAPTIVE_SUCCESS_PERSIST_MIN_INTERVAL,
) == Some(token)
}
fn provider_key_adaptive_success_persist_gate_reset(key_id: &str) {
ADAPTIVE_SUCCESS_PERSIST_GATE.remove(&provider_key_adaptive_success_persist_gate_key(key_id));
}
fn provider_key_adaptive_success_persist_gate_key(key_id: &str) -> String {
format!("adaptive-success:{key_id}")
}
async fn record_health_failure_effect(
@@ -618,65 +854,73 @@ async fn record_health_failure_effect(
return;
}
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
let _effect_guard = effect_lock.lock().await;
let is_pool_provider = local_execution_plan_uses_pool(state, context.plan).await;
let observed_at_unix_secs = current_unix_secs();
let Some(health_by_format) = project_local_failure_health(
current_key.health_by_format.as_ref(),
api_format,
effect.classification,
effect.status_code,
observed_at_unix_secs,
) else {
return;
};
let consecutive_failures = health_by_format
.get(api_format)
.and_then(|value| value.get("consecutive_failures"))
.and_then(Value::as_u64)
.unwrap_or(0);
let circuit_breaker_update_owned = if is_pool_provider {
None
} else {
project_local_key_circuit_failure(
current_key.circuit_breaker_by_format.as_ref(),
api_format,
observed_at_unix_secs,
consecutive_failures,
current_key.max_probe_interval_minutes,
)
};
let circuit_breaker_update = if is_pool_provider {
None
} else {
circuit_breaker_update_owned
.as_ref()
.or(current_key.circuit_breaker_by_format.as_ref())
};
provider_key_health_success_persist_gate_reset(&context.plan.key_id, api_format);
if let Err(err) = state
.update_provider_catalog_key_success_health_state(
&context.plan.key_id,
current_key.is_active,
Some(&health_by_format),
circuit_breaker_update,
)
.await
{
warn!(
"gateway orchestration effects: failed to persist health failure projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
let Some(health_by_format) = project_local_failure_health(
current_key.health_by_format.as_ref(),
api_format,
effect.classification,
effect.status_code,
observed_at_unix_secs,
) else {
return;
};
let consecutive_failures = health_by_format
.get(api_format)
.and_then(|value| value.get("consecutive_failures"))
.and_then(Value::as_u64)
.unwrap_or(0);
let circuit_breaker_by_format = if is_pool_provider {
None
} else {
project_local_key_circuit_failure(
current_key.circuit_breaker_by_format.as_ref(),
api_format,
observed_at_unix_secs,
consecutive_failures,
current_key.max_probe_interval_minutes,
)
.or_else(|| current_key.circuit_breaker_by_format.clone())
};
let update = ProviderCatalogKeyHealthStateUpdate {
key_id: context.plan.key_id.clone(),
expected_health_by_format: current_key.health_by_format,
expected_circuit_breaker_by_format: current_key.circuit_breaker_by_format,
health_by_format: Some(health_by_format),
circuit_breaker_by_format,
};
match state
.compare_and_update_provider_catalog_key_health_state(&update)
.await
{
Ok(true) => return,
Ok(false) => tokio::task::yield_now().await,
Err(err) => {
warn!(
"gateway orchestration effects: failed to persist health failure projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
return;
}
}
}
warn!(
"gateway orchestration effects: health failure CAS retries exhausted for provider {} endpoint {} key {}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
);
}
async fn record_health_success_effect(
@@ -691,66 +935,86 @@ async fn record_health_success_effect(
return;
}
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
// Health updates replace both JSON snapshots in one write. Serialize the success
// read/project/write with failure and circuit-clear effects for this provider key so a
// stale success snapshot cannot overwrite a newer failure counter or open circuit.
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
let _effect_guard = effect_lock.lock().await;
let is_pool_provider = local_execution_plan_uses_pool(state, context.plan).await;
let Some(health_by_format) =
project_local_success_health(current_key.health_by_format.as_ref(), api_format)
else {
return;
};
let circuit_breaker_update_owned = if is_pool_provider {
None
} else {
current_key
.circuit_breaker_by_format
.as_ref()
.and_then(|current| project_local_key_circuit_closed(Some(current), api_format))
};
if current_key.health_by_format.as_ref() == Some(&health_by_format)
&& ((is_pool_provider && current_key.circuit_breaker_by_format.is_none())
|| (!is_pool_provider
&& circuit_breaker_update_owned.as_ref()
== current_key.circuit_breaker_by_format.as_ref()))
{
return;
}
let circuit_breaker_update = if is_pool_provider {
None
} else {
circuit_breaker_update_owned
.as_ref()
.or(current_key.circuit_breaker_by_format.as_ref())
};
let mut persist_gate_checked = false;
if !provider_key_health_success_persist_gate_allows(
&context.plan.key_id,
api_format,
circuit_breaker_update_owned.is_some(),
) {
return;
}
if let Err(err) = state
.update_provider_catalog_key_health_state(
&context.plan.key_id,
current_key.is_active,
Some(&health_by_format),
circuit_breaker_update,
)
.await
{
warn!(
"gateway orchestration effects: failed to persist health success projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
let Some(health_by_format) =
project_local_success_health(current_key.health_by_format.as_ref(), api_format)
else {
return;
};
let circuit_breaker_update_owned = if is_pool_provider {
None
} else {
current_key
.circuit_breaker_by_format
.as_ref()
.and_then(|current| project_local_key_circuit_closed(Some(current), api_format))
};
if current_key.health_by_format.as_ref() == Some(&health_by_format)
&& ((is_pool_provider && current_key.circuit_breaker_by_format.is_none())
|| (!is_pool_provider
&& circuit_breaker_update_owned.as_ref()
== current_key.circuit_breaker_by_format.as_ref()))
{
return;
}
if !persist_gate_checked {
if !provider_key_health_success_persist_gate_allows(
&context.plan.key_id,
api_format,
circuit_breaker_update_owned.is_some(),
) {
return;
}
persist_gate_checked = true;
}
let circuit_breaker_by_format = if is_pool_provider {
None
} else {
circuit_breaker_update_owned.or_else(|| current_key.circuit_breaker_by_format.clone())
};
let update = ProviderCatalogKeyHealthStateUpdate {
key_id: context.plan.key_id.clone(),
expected_health_by_format: current_key.health_by_format,
expected_circuit_breaker_by_format: current_key.circuit_breaker_by_format,
health_by_format: Some(health_by_format),
circuit_breaker_by_format,
};
match state
.compare_and_update_provider_catalog_key_health_state(&update)
.await
{
Ok(true) => return,
Ok(false) => tokio::task::yield_now().await,
Err(err) => {
warn!(
"gateway orchestration effects: failed to persist health success projection for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
return;
}
}
}
warn!(
"gateway orchestration effects: health success CAS retries exhausted for provider {} endpoint {} key {}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
);
}
fn provider_key_health_success_persist_gate_allows(
@@ -871,32 +1135,47 @@ async fn clear_pool_key_circuit_breaker(
state: &AppState,
context: LocalExecutionEffectContext<'_>,
) {
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
if current_key.circuit_breaker_by_format.is_none() {
return;
}
let effect_lock = PROVIDER_KEY_EFFECT_LOCKS.lock_for(&context.plan.key_id);
let _effect_guard = effect_lock.lock().await;
if let Err(err) = state
.update_provider_catalog_key_health_state(
&context.plan.key_id,
current_key.is_active,
current_key.health_by_format.as_ref(),
None,
)
.await
{
warn!(
"gateway orchestration effects: failed to clear pool key circuit for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
for _ in 0..PROVIDER_KEY_STATE_CAS_MAX_ATTEMPTS {
let Some(current_key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&context.plan.key_id))
.await
.ok()
.and_then(|mut keys| keys.drain(..).next())
else {
return;
};
if current_key.circuit_breaker_by_format.is_none() {
return;
}
let update = ProviderCatalogKeyHealthStateUpdate {
key_id: context.plan.key_id.clone(),
expected_health_by_format: current_key.health_by_format.clone(),
expected_circuit_breaker_by_format: current_key.circuit_breaker_by_format,
health_by_format: current_key.health_by_format,
circuit_breaker_by_format: None,
};
match state
.compare_and_update_provider_catalog_key_health_state(&update)
.await
{
Ok(true) => return,
Ok(false) => tokio::task::yield_now().await,
Err(err) => {
warn!(
"gateway orchestration effects: failed to clear pool key circuit for provider {} endpoint {} key {}: {:?}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id, err
);
return;
}
}
}
warn!(
"gateway orchestration effects: clear pool key circuit CAS retries exhausted for provider {} endpoint {} key {}",
context.plan.provider_id, context.plan.endpoint_id, context.plan.key_id
);
}
async fn record_oauth_invalidation_effect(
@@ -1292,6 +1571,7 @@ mod tests {
LocalAdaptiveRateLimitEffect, LocalAdaptiveSuccessEffect, LocalAttemptFailureEffect,
LocalExecutionEffect, LocalExecutionEffectContext, LocalHealthFailureEffect,
LocalHealthSuccessEffect, LocalOAuthInvalidationEffect, LocalPoolErrorEffect,
ProviderKeyEffectLockPool,
};
use crate::data::{GatewayDataConfig, GatewayDataState};
use crate::orchestration::LocalFailoverClassification;
@@ -1363,6 +1643,37 @@ mod tests {
));
}
#[test]
fn provider_key_effect_lock_pool_prunes_geometrically_and_keeps_active_locks() {
let pool = ProviderKeyEffectLockPool::new(4);
let locks = (0..10)
.map(|index| pool.lock_for(&format!("key-{index}")))
.collect::<Vec<_>>();
{
let state = pool.state.lock().expect("effect lock pool should lock");
assert_eq!(state.entries.len(), 10);
assert_eq!(state.prune_count, 2);
assert_eq!(state.next_growth_prune_at, 16);
}
for (index, expected) in locks.iter().enumerate() {
let existing = pool.lock_for(&format!("key-{index}"));
assert!(Arc::ptr_eq(expected, &existing));
}
let hot_lock = Arc::clone(&locks[0]);
drop(locks);
for _ in 0..32 {
let existing = pool.lock_for("key-0");
assert!(Arc::ptr_eq(&hot_lock, &existing));
}
let state = pool.state.lock().expect("effect lock pool should lock");
assert_eq!(state.entries.len(), 1);
assert!(state.prune_count >= 3);
}
fn session_affinity() -> ClientSessionAffinity {
ClientSessionAffinity::new(
Some("generic".to_string()),
@@ -2538,6 +2849,45 @@ mod tests {
);
}
#[tokio::test]
async fn runtime_health_failure_does_not_reactivate_admin_disabled_key() {
let mut key = sample_health_key();
key.is_active = false;
let state = health_state_with_key(key);
let plan = sample_plan();
apply_local_execution_effect(
&state,
LocalExecutionEffectContext {
plan: &plan,
report_context: None,
},
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
status_code: 503,
classification: LocalFailoverClassification::RetryUpstreamFailure,
}),
)
.await;
let stored_key = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
.await
.expect("provider catalog keys should load")
.into_iter()
.next()
.expect("stored key should exist");
assert!(!stored_key.is_active);
assert_eq!(
stored_key
.health_by_format
.as_ref()
.and_then(|value| value.get("openai:chat"))
.and_then(|value| value.get("consecutive_failures"))
.and_then(Value::as_u64),
Some(1)
);
}
#[tokio::test]
async fn health_failure_opens_circuit_after_eight_consecutive_failures() {
let state = health_state();
@@ -2583,6 +2933,59 @@ mod tests {
);
}
#[tokio::test]
async fn concurrent_health_failures_for_one_key_do_not_lose_updates() {
let state = health_state();
let plan = sample_plan();
let mut tasks = tokio::task::JoinSet::new();
for _ in 0..8 {
let state = state.clone();
let plan = plan.clone();
tasks.spawn(async move {
apply_local_execution_effect(
&state,
LocalExecutionEffectContext {
plan: &plan,
report_context: None,
},
LocalExecutionEffect::HealthFailure(LocalHealthFailureEffect {
status_code: 503,
classification: LocalFailoverClassification::RetryUpstreamFailure,
}),
)
.await;
});
}
while let Some(result) = tasks.join_next().await {
result.expect("health failure task should complete");
}
let stored_key = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
.await
.expect("provider catalog keys should load")
.into_iter()
.next()
.expect("stored key should exist");
let circuit = stored_key
.circuit_breaker_by_format
.as_ref()
.and_then(|value| value.get("openai:chat"))
.expect("format circuit should be stored");
assert_eq!(
stored_key
.health_by_format
.as_ref()
.and_then(|value| value.get("openai:chat"))
.and_then(|value| value.get("consecutive_failures"))
.and_then(Value::as_u64),
Some(8)
);
assert_eq!(circuit["open"], json!(true));
assert_eq!(circuit["reason"], json!("consecutive_failures_8"));
}
#[tokio::test]
async fn pool_health_failure_does_not_open_key_circuit_after_eight_consecutive_failures() {
let state = pool_health_state();
@@ -2879,6 +3282,44 @@ mod tests {
);
}
#[tokio::test]
async fn adaptive_rate_limit_effect_preserves_quota_status_owned_by_reports() {
let mut key = sample_adaptive_key();
key.status_snapshot = Some(json!({
"quota": {"remaining": 9},
"oauth": {"invalid": false},
"observation_count": 0
}));
let state = health_state_with_key(key);
let plan = sample_plan();
apply_local_execution_effect(
&state,
LocalExecutionEffectContext {
plan: &plan,
report_context: None,
},
LocalExecutionEffect::AdaptiveRateLimit(LocalAdaptiveRateLimitEffect {
status_code: 429,
classification: LocalFailoverClassification::RetryUpstreamFailure,
headers: None,
}),
)
.await;
let stored_key = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&plan.key_id))
.await
.expect("provider catalog keys should load")
.into_iter()
.next()
.expect("stored key should exist");
let status = stored_key.status_snapshot.expect("status should exist");
assert_eq!(status["quota"], json!({"remaining":9}));
assert_eq!(status["oauth"], json!({"invalid":false}));
assert_eq!(status["observation_count"], json!(1));
}
#[tokio::test]
async fn adaptive_rate_limit_effect_ignores_fixed_limit_key() {
let state = fixed_limit_state();
@@ -3,6 +3,7 @@ use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
use aether_admin::provider::quota as admin_provider_quota_pure;
use aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyRuntimeMetadataUpdate;
use aether_provider_pool::grok_quota_window_key_for_model;
use aether_usage_runtime::{
extract_gemini_file_mapping_entries, gemini_file_mapping_cache_key, normalize_gemini_file_name,
@@ -22,6 +23,7 @@ use crate::{AppState, GatewayError};
const CODEX_QUOTA_CACHE_TTL_SECONDS: u64 = 30;
const CODEX_QUOTA_CACHE_MAX_ENTRIES: usize = 4096;
const RUNTIME_METADATA_CAS_MAX_ATTEMPTS: usize = 16;
type HeaderFingerprintCache = Mutex<HashMap<String, (String, Instant)>>;
@@ -29,6 +31,16 @@ static CODEX_QUOTA_HEADER_FINGERPRINT_CACHE: OnceLock<HeaderFingerprintCache> =
static GROK_CHINESE_WAIT_DURATION_RE: OnceLock<Regex> = OnceLock::new();
static GROK_ENGLISH_WAIT_DURATION_RE: OnceLock<Regex> = OnceLock::new();
fn upstream_metadata_namespace_value(
upstream_metadata: Option<&Value>,
namespace: &str,
) -> Option<Value> {
upstream_metadata
.and_then(Value::as_object)
.and_then(|metadata| metadata.get(namespace))
.cloned()
}
#[derive(Debug, Clone, Copy)]
pub(crate) enum LocalReportEffect<'a> {
Sync {
@@ -172,6 +184,18 @@ fn merge_metadata_object(
Some(Value::Object(merged))
}
fn quota_status_snapshot_patch(status_snapshot: Option<&Value>) -> Value {
let mut patch = serde_json::Map::new();
if let Some(quota) = status_snapshot
.and_then(Value::as_object)
.and_then(|snapshot| snapshot.get("quota"))
.cloned()
{
patch.insert("quota".to_string(), quota);
}
Value::Object(patch)
}
fn grok_report_context_model(report_context: Option<&Value>) -> Option<String> {
report_context
.and_then(|context| context.get("mapped_model"))
@@ -321,6 +345,7 @@ async fn sync_gemini_cli_credits_from_report(
Some(value) => value,
None => return Ok(false),
};
let now_unix_secs = current_unix_secs();
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
@@ -345,22 +370,21 @@ async fn sync_gemini_cli_credits_from_report(
return Ok(false);
}
let now_unix_secs = current_unix_secs();
let mut gemini_cli_bucket = key
.upstream_metadata
let expected_namespace_value =
upstream_metadata_namespace_value(key.upstream_metadata.as_ref(), "gemini_cli");
let mut gemini_cli_bucket = expected_namespace_value
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("gemini_cli"))
.and_then(Value::as_object)
.cloned()
.unwrap_or_else(serde_json::Map::new);
gemini_cli_bucket.insert("credits".to_string(), credits);
gemini_cli_bucket.insert("credits".to_string(), credits.clone());
gemini_cli_bucket.insert("updated_at".to_string(), json!(now_unix_secs));
let namespace_value = Value::Object(gemini_cli_bucket);
let updated_upstream_metadata = merge_metadata_object(
key.upstream_metadata.as_ref(),
"gemini_cli",
Value::Object(gemini_cli_bucket),
namespace_value.clone(),
);
let updated_status_snapshot = sync_provider_key_quota_status_snapshot(
key.status_snapshot.as_ref(),
@@ -368,15 +392,22 @@ async fn sync_gemini_cli_credits_from_report(
updated_upstream_metadata.as_ref(),
"report_effect",
);
let mut updated_key = key;
updated_key.upstream_metadata = updated_upstream_metadata;
updated_key.status_snapshot = updated_status_snapshot;
updated_key.updated_at_unix_secs = Some(now_unix_secs);
Ok(state
.update_provider_catalog_key(&updated_key)
.await?
.is_some())
let persisted = state
.update_provider_catalog_key_runtime_metadata(&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: key_id.clone(),
namespace: "gemini_cli".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: namespace_value,
status_snapshot_patch: quota_status_snapshot_patch(updated_status_snapshot.as_ref()),
updated_at_unix_secs: Some(now_unix_secs),
})
.await?;
if persisted {
return Ok(true);
}
// Credits returned by the provider are an authoritative snapshot; do not
// replay it over a newer local namespace after a CAS conflict.
Ok(false)
}
fn grok_quota_reset_after_seconds(
@@ -479,70 +510,85 @@ async fn sync_grok_quota_from_report_context(
return Ok(false);
};
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(false);
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id))
.await?
.into_iter()
.next()
else {
return Ok(false);
};
if !provider.provider_type.trim().eq_ignore_ascii_case("grok") {
return Ok(false);
}
let Some(grok_bucket) = key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("grok"))
.and_then(Value::as_object)
.cloned()
else {
return Ok(false);
};
let mut updated_grok_bucket = grok_bucket;
let now_unix_secs = current_unix_secs();
if !grok_apply_quota_feedback(
&mut updated_grok_bucket,
model.as_str(),
status_code,
grok_quota_reset_after_seconds(body_json, report_context),
now_unix_secs,
) {
return Ok(false);
for attempt in 0..RUNTIME_METADATA_CAS_MAX_ATTEMPTS {
let Some(key) = state
.read_provider_catalog_keys_by_ids(std::slice::from_ref(&key_id))
.await?
.into_iter()
.next()
else {
return Ok(false);
};
let Some(provider) = state
.read_provider_catalog_providers_by_ids(std::slice::from_ref(&key.provider_id))
.await?
.into_iter()
.next()
else {
return Ok(false);
};
if !provider.provider_type.trim().eq_ignore_ascii_case("grok") {
return Ok(false);
}
let expected_namespace_value =
upstream_metadata_namespace_value(key.upstream_metadata.as_ref(), "grok");
let Some(grok_bucket) = expected_namespace_value
.as_ref()
.and_then(Value::as_object)
.cloned()
else {
return Ok(false);
};
let mut updated_grok_bucket = grok_bucket;
if !grok_apply_quota_feedback(
&mut updated_grok_bucket,
model.as_str(),
status_code,
grok_quota_reset_after_seconds(body_json, report_context),
now_unix_secs,
) {
return Ok(false);
}
grok_mark_quota_bucket_updated(&mut updated_grok_bucket, now_unix_secs);
let namespace_value = Value::Object(updated_grok_bucket);
let updated_upstream_metadata = merge_metadata_object(
key.upstream_metadata.as_ref(),
"grok",
namespace_value.clone(),
);
let updated_status_snapshot = sync_provider_key_quota_status_snapshot(
key.status_snapshot.as_ref(),
provider.provider_type.as_str(),
updated_upstream_metadata.as_ref(),
"report_effect",
);
let persisted = state
.update_provider_catalog_key_runtime_metadata(
&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: key_id.clone(),
namespace: "grok".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: namespace_value,
status_snapshot_patch: quota_status_snapshot_patch(
updated_status_snapshot.as_ref(),
),
updated_at_unix_secs: Some(now_unix_secs),
},
)
.await?;
if persisted {
return Ok(true);
}
if attempt + 1 < RUNTIME_METADATA_CAS_MAX_ATTEMPTS {
let backoff_us = 50_u64.saturating_mul((attempt + 1) as u64).min(1_000);
tokio::time::sleep(Duration::from_micros(backoff_us)).await;
}
}
grok_mark_quota_bucket_updated(&mut updated_grok_bucket, now_unix_secs);
let updated_upstream_metadata = merge_metadata_object(
key.upstream_metadata.as_ref(),
"grok",
Value::Object(updated_grok_bucket),
);
let updated_status_snapshot = sync_provider_key_quota_status_snapshot(
key.status_snapshot.as_ref(),
provider.provider_type.as_str(),
updated_upstream_metadata.as_ref(),
"report_effect",
);
let mut updated_key = key;
updated_key.upstream_metadata = updated_upstream_metadata;
updated_key.status_snapshot = updated_status_snapshot;
updated_key.updated_at_unix_secs = Some(now_unix_secs);
Ok(state
.update_provider_catalog_key_runtime_state(&updated_key)
.await?
.is_some())
Ok(false)
}
async fn apply_local_sync_report_effect(state: &AppState, payload: &GatewaySyncReportRequest) {
@@ -820,7 +866,7 @@ async fn sync_codex_quota_from_response_headers(
.into_iter()
.next()
else {
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint.clone(), now);
return Ok(false);
};
@@ -830,53 +876,56 @@ async fn sync_codex_quota_from_response_headers(
.into_iter()
.next()
else {
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint.clone(), now);
return Ok(false);
};
if !provider.provider_type.trim().eq_ignore_ascii_case("codex") {
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint.clone(), now);
return Ok(false);
}
let current_codex = key
.upstream_metadata
.as_ref()
.and_then(Value::as_object)
.and_then(|metadata| metadata.get("codex"))
.and_then(Value::as_object)
.cloned()
let expected_namespace_value =
upstream_metadata_namespace_value(key.upstream_metadata.as_ref(), "codex");
let current_codex = expected_namespace_value
.clone()
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(serde_json::Map::new);
let current_codex = Value::Object(current_codex);
let Some(current_fingerprint) = fingerprint_codex_payload(&current_codex) else {
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint.clone(), now);
return Ok(false);
};
if current_fingerprint == incoming_fingerprint {
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint.clone(), now);
return Ok(false);
}
let updated_upstream_metadata =
merge_metadata_object(key.upstream_metadata.as_ref(), "codex", parsed);
merge_metadata_object(key.upstream_metadata.as_ref(), "codex", parsed.clone());
let updated_status_snapshot = sync_provider_key_quota_status_snapshot(
key.status_snapshot.as_ref(),
provider.provider_type.as_str(),
updated_upstream_metadata.as_ref(),
"response_headers",
);
let mut updated_key = key;
updated_key.upstream_metadata = updated_upstream_metadata;
updated_key.status_snapshot = updated_status_snapshot;
updated_key.updated_at_unix_secs = Some(now_unix_secs);
let updated = state
.update_provider_catalog_key_runtime_state(&updated_key)
.await?
.is_some();
.update_provider_catalog_key_runtime_metadata(&ProviderCatalogKeyRuntimeMetadataUpdate {
key_id: key_id.clone(),
namespace: "codex".to_string(),
expected_upstream_metadata_value: expected_namespace_value,
upstream_metadata_value: parsed.clone(),
status_snapshot_patch: quota_status_snapshot_patch(updated_status_snapshot.as_ref()),
updated_at_unix_secs: Some(now_unix_secs),
})
.await?;
if updated {
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint, now);
set_cached_codex_quota_fingerprint(&key_id, incoming_fingerprint.clone(), now);
return Ok(true);
}
Ok(updated)
// Response headers describe an authoritative quota snapshot. A CAS
// conflict means a newer snapshot/delta won, so avoid replaying stale
// data over it.
return Ok(false);
}
#[cfg(test)]
@@ -892,7 +941,86 @@ pub(crate) fn clear_local_report_effect_caches_for_tests() {
#[cfg(test)]
mod tests {
use super::*;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data_contracts::repository::provider_catalog::{
StoredProviderCatalogKey, StoredProviderCatalogProvider,
};
use serde_json::json;
use std::sync::Arc;
use crate::data::GatewayDataState;
#[tokio::test]
async fn gemini_report_metadata_write_preserves_adaptive_and_other_provider_state() {
let provider = StoredProviderCatalogProvider::new(
"gemini-provider".to_string(),
"Gemini CLI".to_string(),
None,
"gemini_cli".to_string(),
)
.expect("provider should build");
let mut key = StoredProviderCatalogKey::new(
"gemini-key".to_string(),
"gemini-provider".to_string(),
"Gemini Key".to_string(),
"oauth".to_string(),
None,
true,
)
.expect("key should build");
key.learned_rpm_limit = Some(12);
key.rpm_429_count = Some(3);
key.upstream_metadata = Some(json!({
"gemini_cli": {"credits":{"remaining":9}},
"codex": {"remaining":7}
}));
key.status_snapshot = Some(json!({
"quota": {"source":"old"},
"observation_count": 4,
"learning_confidence": 0.7,
"oauth": {"invalid":false}
}));
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![key],
));
let state = AppState::new()
.expect("gateway state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(repository),
);
assert!(sync_gemini_cli_credits_from_report(
&state,
Some(&json!({"key_id":"gemini-key"})),
Some(json!({"remaining":3,"total":10}))
)
.await
.expect("report metadata should update"));
let stored = state
.read_provider_catalog_keys_by_ids(&["gemini-key".to_string()])
.await
.expect("key should reload")
.pop()
.expect("key should exist");
assert_eq!(stored.learned_rpm_limit, Some(12));
assert_eq!(stored.rpm_429_count, Some(3));
assert_eq!(
stored.upstream_metadata.as_ref().unwrap()["codex"],
json!({"remaining":7})
);
assert_eq!(
stored.upstream_metadata.as_ref().unwrap()["gemini_cli"]["credits"]["remaining"],
json!(3)
);
let status = stored.status_snapshot.expect("status should exist");
assert_eq!(status["observation_count"], json!(4));
assert_eq!(status["learning_confidence"], json!(0.7));
assert_eq!(status["oauth"], json!({"invalid":false}));
assert_eq!(status["quota"]["provider_type"], json!("gemini_cli"));
}
#[test]
fn grok_quota_feedback_decrements_the_matching_window() {
File diff suppressed because it is too large Load Diff
@@ -104,13 +104,29 @@ pub(crate) trait RequestCandidateRuntimeReader {
}
#[async_trait]
pub(crate) trait RequestCandidateRuntimeWriter {
pub(crate) trait RequestCandidateRuntimeWriter: Sync {
fn has_request_candidate_data_writer(&self) -> bool;
async fn upsert_request_candidate(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<Option<StoredRequestCandidate>, GatewayError>;
async fn enqueue_request_candidate_status(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<Option<()>, GatewayError> {
self.upsert_request_candidate(candidate)
.await
.map(|stored| stored.map(|_| ()))
}
fn try_enqueue_request_candidate_status(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<(), UpsertRequestCandidateRecord> {
Err(candidate)
}
}
#[async_trait]
@@ -276,7 +292,7 @@ pub(crate) fn snapshot_local_request_candidate_status(
})
}
async fn persist_local_request_candidate_status_record(
pub(crate) async fn persist_local_request_candidate_status_record(
state: &(impl RequestCandidateRuntimeWriter + ?Sized),
record: UpsertRequestCandidateRecord,
) {
@@ -301,13 +317,13 @@ async fn persist_local_request_candidate_status_record(
return;
}
match state.upsert_request_candidate(record).await {
Ok(Some(stored)) => {
match state.enqueue_request_candidate_status(record).await {
Ok(Some(())) => {
debug!(
event_name = "request_candidate_status_persisted",
log_type = "event",
request_id = %request_id,
candidate_id = %stored.id,
candidate_id = %candidate_id,
candidate_index,
retry_index,
status = request_candidate_status_label(status),
@@ -400,11 +416,10 @@ pub(crate) async fn record_local_request_candidate_extra_data(
persist_local_request_candidate_status_record(state, record).await;
}
pub(crate) async fn record_local_request_candidate_status_snapshot(
state: &(impl RequestCandidateRuntimeWriter + ?Sized),
fn build_local_request_candidate_status_snapshot_record(
snapshot: &LocalRequestCandidateStatusSnapshot,
status_update: SchedulerRequestCandidateStatusUpdate,
) {
) -> UpsertRequestCandidateRecord {
let SchedulerRequestCandidateStatusUpdate {
status,
status_code,
@@ -414,7 +429,7 @@ pub(crate) async fn record_local_request_candidate_status_snapshot(
started_at_unix_ms,
finished_at_unix_ms,
} = status_update;
let record = UpsertRequestCandidateRecord {
UpsertRequestCandidateRecord {
id: snapshot.candidate_id.clone(),
request_id: snapshot.request_id.clone(),
user_id: snapshot.user_id.clone(),
@@ -439,7 +454,27 @@ pub(crate) async fn record_local_request_candidate_status_snapshot(
created_at_unix_ms: None,
started_at_unix_ms,
finished_at_unix_ms,
};
}
}
pub(crate) fn try_enqueue_local_request_candidate_status_snapshot(
state: &(impl RequestCandidateRuntimeWriter + ?Sized),
snapshot: &LocalRequestCandidateStatusSnapshot,
status_update: SchedulerRequestCandidateStatusUpdate,
) -> Result<(), UpsertRequestCandidateRecord> {
let record = build_local_request_candidate_status_snapshot_record(snapshot, status_update);
if !should_persist_request_candidate_status(record.status) {
return Ok(());
}
state.try_enqueue_request_candidate_status(record)
}
pub(crate) async fn record_local_request_candidate_status_snapshot(
state: &(impl RequestCandidateRuntimeWriter + ?Sized),
snapshot: &LocalRequestCandidateStatusSnapshot,
status_update: SchedulerRequestCandidateStatusUpdate,
) {
let record = build_local_request_candidate_status_snapshot_record(snapshot, status_update);
persist_local_request_candidate_status_record(state, record).await;
}
@@ -485,13 +520,13 @@ pub(crate) async fn record_report_request_candidate_status(
return;
}
match state.upsert_request_candidate(record).await {
Ok(Some(stored)) => {
match state.enqueue_request_candidate_status(record).await {
Ok(Some(())) => {
debug!(
event_name = "request_candidate_report_status_persisted",
log_type = "event",
request_id = %request_id_for_log,
candidate_id = %stored.id,
candidate_id = %candidate_id,
candidate_index,
retry_index,
status = request_candidate_status_label(status),
@@ -906,7 +941,7 @@ async fn resolve_report_request_candidate_slot(
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use aether_contracts::{ExecutionPlan, RequestBody};
use aether_data::repository::auth::{
@@ -916,6 +951,7 @@ mod tests {
use aether_data::repository::usage::InMemoryUsageReadRepository;
use aether_data_contracts::repository::candidates::{
RequestCandidateReadRepository, RequestCandidateStatus, StoredRequestCandidate,
UpsertRequestCandidateRecord,
};
use aether_scheduler_core::SchedulerMinimalCandidateSelectionCandidate;
use serde_json::json;
@@ -923,7 +959,9 @@ mod tests {
use super::{
ensure_execution_request_candidate_slot, persist_available_local_candidate,
record_report_request_candidate_status, resolve_request_candidate_required_capabilities,
select_requested_model_capabilities, SchedulerRequestCandidateStatusUpdate,
select_requested_model_capabilities, snapshot_local_request_candidate_status,
try_enqueue_local_request_candidate_status_snapshot, RequestCandidateRuntimeWriter,
SchedulerRequestCandidateStatusUpdate,
};
use crate::data::GatewayDataState;
use crate::AppState;
@@ -954,6 +992,36 @@ mod tests {
)
}
#[derive(Default)]
struct SynchronousStatusWriter {
records: Mutex<Vec<UpsertRequestCandidateRecord>>,
}
#[async_trait::async_trait]
impl RequestCandidateRuntimeWriter for SynchronousStatusWriter {
fn has_request_candidate_data_writer(&self) -> bool {
true
}
async fn upsert_request_candidate(
&self,
_candidate: UpsertRequestCandidateRecord,
) -> Result<Option<StoredRequestCandidate>, crate::GatewayError> {
panic!("synchronous status fast path must not call the async writer")
}
fn try_enqueue_request_candidate_status(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<(), UpsertRequestCandidateRecord> {
self.records
.lock()
.expect("synchronous status records lock")
.push(candidate);
Ok(())
}
}
fn sample_plan() -> ExecutionPlan {
ExecutionPlan {
request_id: "req-request-candidate-seed-123".to_string(),
@@ -978,6 +1046,38 @@ mod tests {
}
}
#[test]
fn streaming_snapshot_uses_synchronous_status_enqueue_fast_path() {
let mut plan = sample_plan();
plan.candidate_id = Some("candidate-streaming-fast-path".to_string());
let snapshot = snapshot_local_request_candidate_status(&plan, None)
.expect("candidate snapshot should build");
let writer = SynchronousStatusWriter::default();
try_enqueue_local_request_candidate_status_snapshot(
&writer,
&snapshot,
SchedulerRequestCandidateStatusUpdate {
status: RequestCandidateStatus::Streaming,
status_code: Some(200),
error_type: None,
error_message: None,
latency_ms: None,
started_at_unix_ms: Some(123),
finished_at_unix_ms: None,
},
)
.expect("streaming status should use the synchronous enqueue path");
let records = writer
.records
.lock()
.expect("synchronous status records lock");
assert_eq!(records.len(), 1);
assert_eq!(records[0].status, RequestCandidateStatus::Streaming);
assert_eq!(records[0].status_code, Some(200));
}
fn sample_minimal_candidate() -> SchedulerMinimalCandidateSelectionCandidate {
SchedulerMinimalCandidateSelectionCandidate {
provider_id: "provider-1".to_string(),
@@ -66,6 +66,24 @@ pub(crate) async fn select_gateway_routing_group(
});
}
// When there are no bindings at all, no principal-specific lookup can
// produce a group. The data-state repository answers this with a cached
// existence query, so the common "routing configured but unused" case
// does not materialize the binding table per API key/user.
let has_bindings = repository.has_any_routing_group_binding().await;
if matches!(has_bindings, Ok(false)) {
let system_default = repository
.find_routing_group(RoutingGroupLookupKey::SystemDefault)
.await
.ok()
.flatten()
.filter(|group| group.enabled);
return Ok(GatewayRoutingGroupSelection {
group: system_default,
source: "system_default".to_string(),
});
}
for (subject_type, subject_id, source) in default_binding_candidates(&input) {
let bindings = repository
.list_routing_group_bindings(&RoutingGroupBindingQuery {
@@ -213,6 +231,89 @@ mod tests {
assert_eq!(selection.group.unwrap().id, "group-1");
}
#[tokio::test]
async fn selects_system_default_when_no_bindings_exist() {
let repository = InMemoryRoutingGroupRepository::default();
repository
.create_routing_group(CreateRoutingGroupRecord {
id: "system-default".to_string(),
name: "system-default".to_string(),
description: None,
enabled: true,
is_system_default: true,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.unwrap();
let selection = select_gateway_routing_group(
&repository,
GatewayRoutingSelectionInput {
explicit_group: None,
user_id: Some("user-1"),
api_key_id: Some("api-key-1"),
user_group_ids: &["user-group-1".to_string()],
},
)
.await
.unwrap();
assert_eq!(selection.source, "system_default");
assert_eq!(selection.group.unwrap().id, "system-default");
}
#[tokio::test]
async fn selects_explicit_group_allowed_by_user_group_binding() {
let repository = InMemoryRoutingGroupRepository::default();
repository
.create_routing_group(CreateRoutingGroupRecord {
id: "private-group".to_string(),
name: "private".to_string(),
description: None,
enabled: true,
is_system_default: false,
config_json: json!({}),
version: 1,
created_at: 1,
updated_at: 1,
published_at: None,
})
.await
.unwrap();
repository
.create_routing_group_binding(CreateRoutingGroupBindingRecord {
id: "binding-explicit".to_string(),
group_id: "private-group".to_string(),
subject_type: RoutingGroupBindingSubject::UserGroup,
subject_id: "team-1".to_string(),
is_default: false,
allow_explicit_select: true,
created_at: 1,
updated_at: 1,
})
.await
.unwrap();
let selection = select_gateway_routing_group(
&repository,
GatewayRoutingSelectionInput {
explicit_group: Some("private-group"),
user_id: Some("user-1"),
api_key_id: Some("api-key-1"),
user_group_ids: &["team-1".to_string()],
},
)
.await
.unwrap();
assert_eq!(selection.source, "explicit_header");
assert_eq!(selection.group.unwrap().id, "private-group");
}
#[tokio::test]
async fn rejects_explicit_group_that_does_not_exist() {
let repository = InMemoryRoutingGroupRepository::default();
+218 -11
View File
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::LazyLock;
@@ -77,6 +78,11 @@ const STAGES: &[&str] = &[
"candidate_resolution_transport_read",
"candidate_resolution_rank",
"direct_reqwest_client_prewarm",
"auth_capacity_total",
"auth_capacity_quota",
"auth_capacity_wallet",
"auth_capacity_cost_estimate",
"auth_capacity_pricing_validation",
"stream_candidate_execute",
"stream_candidate_watchdog_inline",
"stream_candidate_unused",
@@ -92,6 +98,7 @@ const STAGES: &[&str] = &[
"stream_first_frame",
"stream_first_data",
"stream_response_policy",
"stream_response_header_rules",
"stream_response_ready",
"stream_response_build",
"stream_body_inline_first_poll",
@@ -123,6 +130,7 @@ const STAGES: &[&str] = &[
];
const TRACE_STAGE_CAPACITY: usize = 32;
const STAGE_METRICS_ENABLED_ENV: &str = "AETHER_GATEWAY_STAGE_METRICS_ENABLED";
const STAGE_TRACE_MODE_ENV: &str = "AETHER_GATEWAY_STAGE_TRACE_MODE";
const STAGE_TRACE_SLOW_MS_ENV: &str = "AETHER_GATEWAY_STAGE_TRACE_SLOW_MS";
const STAGE_TRACE_SAMPLE_RATE_ENV: &str = "AETHER_GATEWAY_STAGE_TRACE_SAMPLE_RATE";
@@ -130,6 +138,21 @@ const DEFAULT_STAGE_TRACE_SLOW_MS: u64 = 1_000;
static METRICS: LazyLock<Vec<StageMetric>> =
LazyLock::new(|| STAGES.iter().map(|stage| StageMetric::new(stage)).collect());
// Stage observations are on the request hot path. Keep the lookup out of a
// linear scan over every known stage; the map is immutable after startup.
static STAGE_INDEX: LazyLock<HashMap<&'static str, usize>> = LazyLock::new(|| {
STAGES
.iter()
.enumerate()
.map(|(index, stage)| (*stage, index))
.collect()
});
static STAGE_METRICS_ENABLED: LazyLock<bool> = LazyLock::new(|| {
std::env::var(STAGE_METRICS_ENABLED_ENV)
.ok()
.map(|value| stage_metrics_enabled_from_value(value.as_str()))
.unwrap_or(true)
});
static STAGE_TRACE_CONFIG: LazyLock<RequestStageTraceConfig> =
LazyLock::new(read_stage_trace_config);
static STAGE_TRACE_SAMPLE_COUNTER: AtomicU64 = AtomicU64::new(0);
@@ -166,10 +189,11 @@ impl StageMetric {
self.count.fetch_add(1, Ordering::Relaxed);
self.sum_ms.fetch_add(elapsed_ms, Ordering::Relaxed);
update_max(&self.max_ms, elapsed_ms);
for (index, bucket) in BUCKETS_MS.iter().enumerate() {
if elapsed_ms <= *bucket {
self.buckets[index].fetch_add(1, Ordering::Relaxed);
}
// Store one exclusive bucket per observation. Cumulative Prometheus
// buckets are reconstructed when samples are exported, reducing the
// hot-path atomic writes from up to twelve to one.
if let Some(index) = BUCKETS_MS.iter().position(|bucket| elapsed_ms <= *bucket) {
self.buckets[index].fetch_add(1, Ordering::Relaxed);
}
}
@@ -198,13 +222,15 @@ impl StageMetric {
)
.with_labels(stage_label.clone()),
];
let mut cumulative = 0;
for (index, upper_bound_ms) in BUCKETS_MS.iter().enumerate() {
cumulative += self.buckets[index].load(Ordering::Relaxed);
samples.push(
MetricSample::new(
"gateway_stage_latency_bucket",
"Cumulative gateway stage latency observations less than or equal to the bucket upper bound.",
MetricKind::Counter,
self.buckets[index].load(Ordering::Relaxed),
cumulative,
)
.with_labels(vec![
MetricLabel::new("stage", self.stage),
@@ -217,13 +243,37 @@ impl StageMetric {
}
pub(crate) fn observe_gateway_stage_ms(stage: &'static str, elapsed_ms: u64) {
if let Some(metric) = METRICS.iter().find(|metric| metric.stage == stage) {
metric.observe(elapsed_ms);
if !*STAGE_METRICS_ENABLED {
return;
}
if let Some(index) = STAGE_INDEX.get(stage).copied() {
METRICS[index].observe(elapsed_ms);
}
}
fn stage_metrics_enabled_from_value(value: &str) -> bool {
!matches!(
value.trim().to_ascii_lowercase().as_str(),
"0" | "false" | "off" | "no" | "disabled"
)
}
pub(crate) fn gateway_stage_metric_samples() -> Vec<MetricSample> {
let mut samples: Vec<MetricSample> = METRICS.iter().flat_map(StageMetric::samples).collect();
gateway_stage_metric_samples_for_enabled(*STAGE_METRICS_ENABLED)
}
fn gateway_stage_metric_samples_for_enabled(enabled: bool) -> Vec<MetricSample> {
let mut samples: Vec<MetricSample> = if enabled {
METRICS.iter().flat_map(StageMetric::samples).collect()
} else {
Vec::with_capacity(10)
};
samples.push(MetricSample::new(
"gateway_stage_metrics_enabled",
"Whether detailed gateway stage latency histograms are enabled.",
MetricKind::Gauge,
u64::from(enabled),
));
samples.push(MetricSample::new(
"stream_pre_first_byte_spawn_total",
"Number of per-request tasks spawned before the first client-visible stream byte.",
@@ -354,17 +404,34 @@ enum RequestStageTraceMode {
impl RequestStageTrace {
pub(crate) fn from_env() -> Self {
let config = *STAGE_TRACE_CONFIG;
let sampled = config.sample_rate > 0.0 && random_unit_sample() < config.sample_rate;
let sampled = config.mode != RequestStageTraceMode::Off
&& config.sample_rate > 0.0
&& random_unit_sample() < config.sample_rate;
Self::from_config(config, sampled)
}
fn from_config(config: RequestStageTraceConfig, sampled: bool) -> Self {
let records_stages = match config.mode {
RequestStageTraceMode::Off => false,
RequestStageTraceMode::Sample => sampled,
RequestStageTraceMode::Slow | RequestStageTraceMode::All => true,
};
Self {
mode: config.mode,
slow_ms: config.slow_ms,
sampled,
stages: Vec::with_capacity(TRACE_STAGE_CAPACITY),
stages: if records_stages {
Vec::with_capacity(TRACE_STAGE_CAPACITY)
} else {
Vec::new()
},
}
}
pub(crate) fn observe(&mut self, stage: &'static str, elapsed_ms: u64) {
if self.mode == RequestStageTraceMode::Off {
if self.mode == RequestStageTraceMode::Off
|| (self.mode == RequestStageTraceMode::Sample && !self.sampled)
{
return;
}
if let Some((_, existing)) = self
@@ -491,3 +558,143 @@ fn random_unit_sample() -> f64 {
let mixed = value.wrapping_mul(0x2545_f491_4f6c_dd1d);
(mixed as f64) / (u64::MAX as f64)
}
#[cfg(test)]
mod tests {
use super::{
gateway_stage_metric_samples_for_enabled, stage_metrics_enabled_from_value,
RequestStageTrace, RequestStageTraceConfig, RequestStageTraceMode, StageMetric, STAGES,
STAGE_INDEX, TRACE_STAGE_CAPACITY,
};
fn request_trace(mode: RequestStageTraceMode, sampled: bool) -> RequestStageTrace {
RequestStageTrace::from_config(
RequestStageTraceConfig {
mode,
slow_ms: 1_000,
sample_rate: 0.0,
},
sampled,
)
}
#[test]
fn stage_metrics_enabled_parser_defaults_unknown_values_on() {
for value in ["0", "false", "OFF", "no", "disabled"] {
assert!(!stage_metrics_enabled_from_value(value));
}
for value in ["", "1", "true", "yes", "unexpected"] {
assert!(stage_metrics_enabled_from_value(value));
}
}
#[test]
fn stage_index_covers_every_declared_stage() {
assert_eq!(STAGE_INDEX.len(), STAGES.len());
for (index, stage) in STAGES.iter().enumerate() {
assert_eq!(STAGE_INDEX.get(stage).copied(), Some(index));
}
}
#[test]
fn disabled_stage_metrics_skip_histograms_but_keep_full_counters() {
let samples = gateway_stage_metric_samples_for_enabled(false);
let names = samples.iter().map(|sample| sample.name).collect::<Vec<_>>();
assert_eq!(samples.len(), 10);
assert!(!names
.iter()
.any(|name| name.starts_with("gateway_stage_latency_")));
assert!(samples
.iter()
.any(|sample| { sample.name == "gateway_stage_metrics_enabled" && sample.value == 0 }));
for counter in [
"stream_pre_first_byte_spawn_total",
"openai_chat_stream_target_select_raw_candidates_scanned_total",
"openai_chat_stream_payload_build_selected_total",
"openai_chat_stream_payload_build_prefetch_avoided_total",
"openai_chat_stream_target_select_selected_rank_sum",
"openai_chat_model_directive_cache_hit_total",
"openai_chat_model_directive_cache_miss_total",
"chat_pii_redaction_request_cache_hit_total",
"chat_pii_redaction_request_cache_miss_total",
] {
assert!(names.contains(&counter), "missing full counter {counter}");
}
}
#[test]
fn off_request_trace_does_not_allocate_or_record() {
let mut trace = request_trace(RequestStageTraceMode::Off, false);
assert_eq!(trace.stages.capacity(), 0);
trace.observe("stream_total", 42);
assert!(trace.stages.is_empty());
assert_eq!(trace.stages.capacity(), 0);
assert_eq!(trace.into_metadata_value(Some(42)), None);
}
#[test]
fn unsampled_request_trace_does_not_allocate_or_record() {
let mut trace = request_trace(RequestStageTraceMode::Sample, false);
assert_eq!(trace.stages.capacity(), 0);
trace.observe("stream_total", 42);
assert!(trace.stages.is_empty());
assert_eq!(trace.stages.capacity(), 0);
assert_eq!(trace.into_metadata_value(Some(42)), None);
}
#[test]
fn sampled_slow_and_all_request_traces_keep_recording_semantics() {
for (mode, sampled) in [
(RequestStageTraceMode::Sample, true),
(RequestStageTraceMode::Slow, false),
(RequestStageTraceMode::All, false),
] {
let mut trace = request_trace(mode, sampled);
assert_eq!(trace.stages.capacity(), TRACE_STAGE_CAPACITY);
trace.observe("stream_total", 42);
assert_eq!(trace.stages, vec![("stream_total", 42)]);
}
}
#[test]
fn stage_buckets_are_exported_as_cumulative_counts() {
let metric = StageMetric::new("test");
metric.observe(1);
metric.observe(6);
metric.observe(11);
let exclusive = metric
.buckets
.iter()
.map(|bucket| bucket.load(std::sync::atomic::Ordering::Relaxed))
.collect::<Vec<_>>();
assert_eq!(exclusive[0], 1);
assert_eq!(exclusive[1], 0);
assert_eq!(exclusive[2], 1);
assert_eq!(exclusive[3], 1);
assert_eq!(exclusive[4], 0);
assert_eq!(exclusive[5], 0);
assert_eq!(exclusive[6], 0);
assert_eq!(exclusive[7], 0);
assert_eq!(exclusive[8], 0);
assert_eq!(exclusive[9], 0);
assert_eq!(exclusive[10], 0);
let samples = metric.samples();
let bucket_values = samples
.iter()
.filter(|sample| sample.name == "gateway_stage_latency_bucket")
.map(|sample| sample.value)
.collect::<Vec<_>>();
assert_eq!(bucket_values[0], 1);
assert_eq!(bucket_values[1], 1);
assert_eq!(bucket_values[2], 2);
assert_eq!(bucket_values[3], 3);
assert_eq!(bucket_values[10], 3);
}
}
+22 -3
View File
@@ -31,6 +31,7 @@ use super::{
AdminWalletPaymentOrderRecord, AdminWalletRefundRecord, AdminWalletTransactionRecord,
CachedProviderTransportSnapshot, FrontdoorCorsConfig, LocalExecutionRuntimeMissDiagnostic,
LocalProviderDeleteTaskState, ProviderTransportSnapshotCacheKey,
ProviderTransportSnapshotFlight,
};
const DEFAULT_REQUEST_BODY_READ_TIMEOUT_MS: u64 = 120_000;
@@ -54,8 +55,8 @@ const DEFAULT_UPSTREAM_EXECUTION_GATE_LIMIT: usize = 10_000;
const DEFAULT_UPSTREAM_TARGET_GATE_LIMIT: usize = 10_000;
const MAX_AUTH_SNAPSHOT_LOAD_GATE_LIMIT: usize = 1024;
const MAX_CANDIDATE_PLANNING_GATE_LIMIT: usize = 8192;
const MAX_UPSTREAM_EXECUTION_GATE_LIMIT: usize = 16_384;
const MAX_UPSTREAM_TARGET_GATE_LIMIT: usize = 16_384;
const MAX_UPSTREAM_EXECUTION_GATE_LIMIT: usize = 32_768;
const MAX_UPSTREAM_TARGET_GATE_LIMIT: usize = 32_768;
const AUTH_SNAPSHOT_LOAD_GATE_LIMIT_PER_CPU: usize = 16;
const CANDIDATE_PLANNING_GATE_LIMIT_PER_CPU: usize = 256;
const UPSTREAM_EXECUTION_GATE_LIMIT_PER_CPU: usize = 1024;
@@ -408,6 +409,7 @@ pub struct AppState {
pub(crate) scheduler_affinity_epoch: Arc<AtomicU64>,
pub(crate) dashboard_response_cache: Arc<DashboardResponseCache>,
pub(crate) system_config_cache: Arc<SystemConfigCache>,
pub(crate) endpoint_response_header_rules_cache: Arc<JsonValueCache<String>>,
pub(crate) candidate_row_page_cache: Arc<super::super::cache::CandidateRowPageCache>,
pub(crate) candidate_page_cache: Arc<super::super::cache::CandidatePageCache>,
pub(crate) candidate_resolved_page_cache: Arc<super::super::cache::CandidateResolvedPageCache>,
@@ -430,8 +432,9 @@ pub struct AppState {
pub(crate) tunnel: crate::tunnel::EmbeddedTunnelState,
pub(crate) provider_transport_snapshot_cache:
Arc<DashMap<ProviderTransportSnapshotCacheKey, CachedProviderTransportSnapshot>>,
pub(crate) provider_transport_snapshot_cache_generation: Arc<AtomicU64>,
pub(crate) provider_transport_snapshot_inflight:
Arc<DashMap<ProviderTransportSnapshotCacheKey, Arc<TokioMutex<()>>>>,
Arc<DashMap<ProviderTransportSnapshotCacheKey, Arc<ProviderTransportSnapshotFlight>>>,
pub(crate) provider_key_rpm_resets: Arc<StdMutex<HashMap<String, u64>>>,
pub(crate) local_execution_runtime_miss_diagnostics:
Arc<DashMap<String, LocalExecutionRuntimeMissDiagnostic>>,
@@ -547,6 +550,22 @@ mod tests {
parse_gate_limit_value(Some("4096"), TEST_PROFILE, TEST_CAPACITY),
Some(4096)
);
assert_eq!(
parse_gate_limit_value(
Some("20000"),
UPSTREAM_TARGET_GATE_AUTO_PROFILE,
TEST_CAPACITY
),
Some(20_000)
);
assert_eq!(
parse_gate_limit_value(
Some("20000"),
UPSTREAM_EXECUTION_GATE_AUTO_PROFILE,
TEST_CAPACITY
),
Some(20_000)
);
}
#[test]
+98 -1
View File
@@ -1,16 +1,113 @@
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use std::time::Duration;
use super::super::error::GatewayError;
use super::super::provider_transport;
use tokio::sync::Notify;
#[derive(Debug, Clone)]
pub(crate) enum ProviderTransportSnapshotFlightResult {
Published(Arc<provider_transport::GatewayProviderTransportSnapshot>),
Missing,
Invalidated,
Retry,
Error(GatewayError),
}
/// One transport snapshot load per cache key. The completion value is kept
/// alongside the notification so a waiter that is scheduled after the
/// broadcast can still observe the result without a lost wakeup.
#[derive(Debug)]
pub(crate) struct ProviderTransportSnapshotFlight {
generation: u64,
notify: Arc<Notify>,
result: StdMutex<Option<ProviderTransportSnapshotFlightResult>>,
}
impl ProviderTransportSnapshotFlight {
pub(crate) fn new(generation: u64) -> Self {
Self {
generation,
notify: Arc::new(Notify::new()),
result: StdMutex::new(None),
}
}
pub(crate) fn generation(&self) -> u64 {
self.generation
}
fn result(&self) -> Option<ProviderTransportSnapshotFlightResult> {
self.result
.lock()
.map(|result| result.clone())
.unwrap_or_else(|poisoned| poisoned.into_inner().clone())
}
/// Completes the flight once. A clear/cancellation may win the race with
/// the leader; in that case the old result must not overwrite Invalidated.
pub(crate) fn complete(&self, result: ProviderTransportSnapshotFlightResult) -> bool {
let completed = match self.result.lock() {
Ok(mut current) => {
if current.is_some() {
false
} else {
*current = Some(result);
true
}
}
Err(poisoned) => {
let mut current = poisoned.into_inner();
if current.is_some() {
false
} else {
*current = Some(result);
true
}
}
};
if completed {
self.notify.notify_waiters();
}
completed
}
pub(crate) async fn wait(&self) -> ProviderTransportSnapshotFlightResult {
loop {
if let Some(result) = self.result() {
return result;
}
// Register before checking the completion state a second time.
// This closes the result-check/notify race even when the task has
// not been polled yet when the leader broadcasts completion.
let mut notified = Box::pin(self.notify.notified());
notified.as_mut().enable();
if let Some(result) = self.result() {
return result;
}
notified.await;
}
}
}
pub(crate) const AUTH_API_KEY_LAST_USED_TTL: Duration = Duration::from_secs(60);
pub(crate) const AUTH_API_KEY_LAST_USED_MAX_ENTRIES: usize = 10_000;
// Keep normal freshness short for cross-node configuration propagation. A
// stale entry is served while one background refresh runs, so an idle burst
// does not turn expiry into a synchronous database wait.
pub(crate) const PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(1);
pub(crate) const PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL: Duration = Duration::from_secs(30);
// Keep the last known transport usable across normal idle periods. The first
// stale request starts a single background refresh, and every local catalog or
// credential mutation advances the generation and clears the entry.
pub(crate) const PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL: Duration =
Duration::from_secs(5 * 60);
pub(crate) const PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES: usize = 1_024;
#[derive(Debug, Clone)]
pub(crate) struct CachedProviderTransportSnapshot {
pub(crate) loaded_at: std::time::Instant,
pub(crate) generation: u64,
pub(crate) snapshot: Arc<provider_transport::GatewayProviderTransportSnapshot>,
}
+200 -26
View File
@@ -677,40 +677,78 @@ impl AppState {
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_runtime_state(
pub(crate) async fn compare_and_update_provider_catalog_key_adaptive_state(
&self,
key: &provider_catalog::StoredProviderCatalogKey,
) -> Result<Option<provider_catalog::StoredProviderCatalogKey>, GatewayError> {
update: &provider_catalog::ProviderCatalogKeyAdaptiveStateUpdate,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key(key)
.compare_and_update_provider_catalog_key_adaptive_state(update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated.is_some() {
// A CAS conflict means a remote writer changed state, so local runtime reads
// must be refreshed even though this instance did not update the row.
self.invalidate_provider_runtime_state_caches();
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_runtime_metadata(
&self,
update: &provider_catalog::ProviderCatalogKeyRuntimeMetadataUpdate,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_runtime_metadata(update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
// A false result is a namespace CAS conflict. Invalidate the runtime
// snapshots before the caller reloads and retries. Upstream metadata is
// part of the transport snapshot, unlike health/adaptive state.
self.invalidate_provider_transport_runtime_state_caches();
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_status_snapshot(
&self,
update: &provider_catalog::ProviderCatalogKeyStatusSnapshotUpdate,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_status_snapshot(update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.invalidate_provider_runtime_state_caches();
}
Ok(updated)
}
pub(crate) async fn update_provider_catalog_key_success_health_state(
pub(crate) async fn compare_and_update_provider_catalog_key_health_state(
&self,
key_id: &str,
is_active: bool,
health_by_format: Option<&serde_json::Value>,
circuit_breaker_by_format: Option<&serde_json::Value>,
update: &provider_catalog::ProviderCatalogKeyHealthStateUpdate,
) -> Result<bool, GatewayError> {
let updated = self
.data
.update_provider_catalog_key_health_state(
key_id,
is_active,
health_by_format,
circuit_breaker_by_format,
)
.compare_and_update_provider_catalog_key_health_state(update)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
// On conflict another Gateway changed the health snapshot; invalidate all
// health-sensitive caches before the retry reads it back.
self.invalidate_provider_health_routing_caches();
Ok(updated)
}
pub(crate) async fn reset_provider_catalog_key_error_count(
&self,
key_id: &str,
) -> Result<bool, GatewayError> {
let updated = self
.data
.reset_provider_catalog_key_error_count(key_id)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.invalidate_provider_runtime_state_caches();
self.invalidate_provider_health_routing_caches();
}
Ok(updated)
}
@@ -874,9 +912,34 @@ impl AppState {
.ok()
.map(|duration| duration.as_secs());
self.update_provider_catalog_key(&key)
let cleared = self
.data
.clear_provider_catalog_key_oauth_invalid_marker(key_id)
.await
.map(|updated| updated.is_some())
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if !cleared {
return Ok(false);
}
// The marker write already committed. Invalidate before any follow-up
// status patch so error/false paths cannot retain an invalid transport.
self.invalidate_provider_transport_runtime_state_caches();
let oauth = key
.status_snapshot
.as_ref()
.and_then(serde_json::Value::as_object)
.and_then(|snapshot| snapshot.get("oauth"))
.cloned()
.unwrap_or(serde_json::Value::Null);
let updated = self
.update_provider_catalog_key_status_snapshot(
&provider_catalog::ProviderCatalogKeyStatusSnapshotUpdate {
key_id: key_id.to_string(),
status_snapshot_patch: serde_json::json!({"oauth":oauth}),
updated_at_unix_secs: key.updated_at_unix_secs,
},
)
.await?;
Ok(updated)
}
pub(crate) fn put_provider_delete_task(&self, task: LocalProviderDeleteTaskState) {
@@ -998,7 +1061,10 @@ impl AppState {
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
if updated {
self.invalidate_provider_health_routing_caches();
// This administrator-facing API also writes `is_active`, which is
// part of the transport snapshot. Runtime health CAS updates use the
// separate compare-and-update API above and keep transport cached.
self.invalidate_provider_routing_caches();
}
Ok(updated)
}
@@ -1360,7 +1426,7 @@ mod tests {
}
#[tokio::test]
async fn provider_catalog_health_update_keeps_scheduler_affinity_cache() {
async fn provider_catalog_runtime_health_update_keeps_scheduler_affinity_and_transport_cache() {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![sample_endpoint()],
@@ -1373,6 +1439,12 @@ mod tests {
.with_encryption_key_for_tests("test-encryption-key"),
);
let transport_before = state
.read_provider_transport_snapshot_arc("provider-1", "endpoint-1", "key-1")
.await
.expect("provider transport should read")
.expect("provider transport should exist");
let cache_key = "scheduler_affinity:api-key-1:openai:chat:gpt-5";
let ttl = Duration::from_secs(300);
let target = SchedulerAffinityTarget {
@@ -1390,7 +1462,15 @@ mod tests {
}
});
let updated = state
.update_provider_catalog_key_health_state("key-1", true, Some(&health_by_format), None)
.compare_and_update_provider_catalog_key_health_state(
&aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyHealthStateUpdate {
key_id: "key-1".to_string(),
expected_health_by_format: None,
expected_circuit_breaker_by_format: None,
health_by_format: Some(health_by_format),
circuit_breaker_by_format: None,
},
)
.await
.expect("key health update should succeed");
@@ -1400,6 +1480,96 @@ mod tests {
state.read_scheduler_affinity_target(cache_key, ttl),
Some(target)
);
let transport_after = state
.read_provider_transport_snapshot_arc("provider-1", "endpoint-1", "key-1")
.await
.expect("provider transport should read after health update")
.expect("provider transport should still exist");
assert!(
Arc::ptr_eq(&transport_before, &transport_after),
"health-only writes must not invalidate transport configuration"
);
}
#[tokio::test]
async fn provider_catalog_admin_health_update_invalidates_transport_when_active_changes() {
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![sample_endpoint()],
vec![sample_key()],
));
let state = AppState::new()
.expect("app state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
.with_encryption_key_for_tests("test-encryption-key"),
);
let transport_before = state
.read_provider_transport_snapshot_arc("provider-1", "endpoint-1", "key-1")
.await
.expect("provider transport should read")
.expect("provider transport should exist");
assert!(transport_before.key.is_active);
assert!(state
.update_provider_catalog_key_health_state("key-1", false, None, None)
.await
.expect("administrator health update should succeed"));
let transport_after = state
.read_provider_transport_snapshot_arc("provider-1", "endpoint-1", "key-1")
.await
.expect("provider transport should read after active update")
.expect("provider transport should still exist");
assert!(!transport_after.key.is_active);
assert!(!Arc::ptr_eq(&transport_before, &transport_after));
}
#[tokio::test]
async fn clearing_oauth_invalid_marker_invalidates_transport_snapshot() {
let mut key = sample_key();
key.auth_type = "oauth".to_string();
key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
key.oauth_invalid_reason = Some("[REFRESH_FAILED] stale token".to_string());
let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![sample_provider()],
vec![sample_endpoint()],
vec![key],
));
let state = AppState::new()
.expect("app state should build")
.with_data_state_for_tests(
GatewayDataState::with_provider_catalog_repository_for_tests(repository)
.with_encryption_key_for_tests("test-encryption-key"),
);
let transport_before = state
.read_provider_transport_snapshot_arc("provider-1", "endpoint-1", "key-1")
.await
.expect("provider transport should read")
.expect("provider transport should exist");
assert!(state
.clear_provider_catalog_key_oauth_invalid_marker("key-1")
.await
.expect("OAuth invalid marker should clear"));
let transport_after = state
.read_provider_transport_snapshot_arc("provider-1", "endpoint-1", "key-1")
.await
.expect("provider transport should reload")
.expect("provider transport should exist");
let persisted = state
.read_provider_catalog_keys_by_ids(&["key-1".to_string()])
.await
.expect("provider key should reload")
.into_iter()
.next()
.expect("provider key should exist");
assert!(persisted.oauth_invalid_at_unix_secs.is_none());
assert!(persisted.oauth_invalid_reason.is_none());
assert!(!Arc::ptr_eq(&transport_before, &transport_after));
}
#[tokio::test]
@@ -1442,14 +1612,18 @@ mod tests {
);
assert!(state.candidate_page_cache.get(&cache_key, ttl).is_some());
let mut updated_key = sample_key();
updated_key.status_snapshot = Some(serde_json::json!({"source": "runtime"}));
let updated = state
.update_provider_catalog_key_runtime_state(&updated_key)
.update_provider_catalog_key_status_snapshot(
&aether_data_contracts::repository::provider_catalog::ProviderCatalogKeyStatusSnapshotUpdate {
key_id: "key-1".to_string(),
status_snapshot_patch: serde_json::json!({"source": "runtime"}),
updated_at_unix_secs: None,
},
)
.await
.expect("runtime state update should succeed");
assert!(updated.is_some());
assert!(updated);
assert!(state.candidate_page_cache.get(&cache_key, ttl).is_some());
}
}
File diff suppressed because it is too large Load Diff
@@ -485,6 +485,20 @@ impl RequestCandidateRuntimeWriter for AppState {
) -> Result<Option<StoredRequestCandidate>, GatewayError> {
AppState::upsert_request_candidate(self, candidate).await
}
async fn enqueue_request_candidate_status(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<Option<()>, GatewayError> {
AppState::enqueue_request_candidate_status(self, candidate).await
}
fn try_enqueue_request_candidate_status(
&self,
candidate: UpsertRequestCandidateRecord,
) -> Result<(), UpsertRequestCandidateRecord> {
AppState::try_enqueue_request_candidate_status(self, candidate)
}
}
#[async_trait]
+2 -1
View File
@@ -32,7 +32,8 @@ pub(crate) use self::app::{
FrontdoorRuntimeGuardConfig, REQUEST_BODY_BUFFER_PERMIT_BYTES,
};
pub(crate) use self::cache::{
CachedProviderTransportSnapshot, AUTH_API_KEY_LAST_USED_MAX_ENTRIES,
CachedProviderTransportSnapshot, ProviderTransportSnapshotFlight,
ProviderTransportSnapshotFlightResult, AUTH_API_KEY_LAST_USED_MAX_ENTRIES,
AUTH_API_KEY_LAST_USED_TTL, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_MAX_ENTRIES,
PROVIDER_TRANSPORT_SNAPSHOT_CACHE_STALE_TTL, PROVIDER_TRANSPORT_SNAPSHOT_CACHE_TTL,
};
File diff suppressed because it is too large Load Diff
@@ -59,6 +59,7 @@ impl AppState {
if cache_key.is_empty() {
return Ok(None);
}
let cache_generation = self.auth_snapshot_cache.generation();
let snapshot = self
.auth_snapshot_cache
.get_or_load(
@@ -74,10 +75,11 @@ impl AppState {
)
.await?;
if let Some(snapshot) = snapshot.as_ref() {
self.auth_snapshot_cache.insert(
self.auth_snapshot_cache.insert_if_generation(
AuthSnapshotCacheKey::user_api_key_ids(&snapshot.user_id, &snapshot.api_key_id),
Some(snapshot.clone()),
AUTH_API_KEY_SNAPSHOT_RUNTIME_CACHE_TTL,
cache_generation,
);
}
Ok(snapshot)
@@ -4,6 +4,8 @@ use crate::constants::{BUILTIN_DEFAULT_USER_GROUP_ID, DEFAULT_USER_GROUP_CONFIG_
use crate::{AppState, GatewayError};
use std::time::Duration;
// Membership changes should propagate promptly across gateway instances. The
// routing fast path skips this lookup entirely when no bindings exist.
const USER_GROUPS_FOR_USER_CACHE_TTL: Duration = Duration::from_secs(30);
impl AppState {
@@ -20,6 +20,16 @@ fn local_mutation_outcome<T>(outcome: AdminBillingMutationOutcome<T>) -> LocalMu
}
impl AppState {
fn finish_billing_model_context_mutation<T>(
&self,
outcome: LocalMutationOutcome<T>,
) -> LocalMutationOutcome<T> {
if matches!(&outcome, LocalMutationOutcome::Applied(_)) {
self.auth_request_cost_upper_bound_cache.clear();
}
outcome
}
pub(crate) async fn admin_billing_enabled_default_value_exists(
&self,
api_format: &str,
@@ -81,14 +91,18 @@ impl AppState {
.lock()
.expect("admin billing rule store should lock")
.insert(record.id.clone(), record.clone());
return Ok(LocalMutationOutcome::Applied(record));
return Ok(
self.finish_billing_model_context_mutation(LocalMutationOutcome::Applied(record))
);
}
self.data
let outcome = self
.data
.create_admin_billing_rule(input)
.await
.map(local_mutation_outcome)
.map_err(data_error)
.map_err(data_error)?;
Ok(self.finish_billing_model_context_mutation(outcome))
}
pub(crate) async fn list_admin_billing_rules(
@@ -171,14 +185,20 @@ impl AppState {
record.dimension_mappings = input.dimension_mappings.clone();
record.is_enabled = input.is_enabled;
record.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
return Ok(LocalMutationOutcome::Applied(record.clone()));
return Ok(
self.finish_billing_model_context_mutation(LocalMutationOutcome::Applied(
record.clone(),
)),
);
}
self.data
let outcome = self
.data
.update_admin_billing_rule(rule_id, input)
.await
.map(local_mutation_outcome)
.map_err(data_error)
.map_err(data_error)?;
Ok(self.finish_billing_model_context_mutation(outcome))
}
pub(crate) async fn create_admin_billing_collector(
@@ -207,14 +227,18 @@ impl AppState {
.lock()
.expect("admin billing collector store should lock")
.insert(record.id.clone(), record.clone());
return Ok(LocalMutationOutcome::Applied(record));
return Ok(
self.finish_billing_model_context_mutation(LocalMutationOutcome::Applied(record))
);
}
self.data
let outcome = self
.data
.create_admin_billing_collector(input)
.await
.map(local_mutation_outcome)
.map_err(data_error)
.map_err(data_error)?;
Ok(self.finish_billing_model_context_mutation(outcome))
}
pub(crate) async fn list_admin_billing_collectors(
@@ -313,14 +337,20 @@ impl AppState {
record.priority = input.priority;
record.is_enabled = input.is_enabled;
record.updated_at_unix_secs = chrono::Utc::now().timestamp().max(0) as u64;
return Ok(LocalMutationOutcome::Applied(record.clone()));
return Ok(
self.finish_billing_model_context_mutation(LocalMutationOutcome::Applied(
record.clone(),
)),
);
}
self.data
let outcome = self
.data
.update_admin_billing_collector(collector_id, input)
.await
.map(local_mutation_outcome)
.map_err(data_error)
.map_err(data_error)?;
Ok(self.finish_billing_model_context_mutation(outcome))
}
pub(crate) async fn apply_admin_billing_preset(
@@ -389,23 +419,27 @@ impl AppState {
}
}
}
return Ok(LocalMutationOutcome::Applied(
AdminBillingPresetApplyResult {
preset: preset.to_string(),
mode: mode.to_string(),
created,
updated,
skipped,
errors: Vec::new(),
},
));
return Ok(
self.finish_billing_model_context_mutation(LocalMutationOutcome::Applied(
AdminBillingPresetApplyResult {
preset: preset.to_string(),
mode: mode.to_string(),
created,
updated,
skipped,
errors: Vec::new(),
},
)),
);
}
self.data
let outcome = self
.data
.apply_admin_billing_preset(preset, mode, collectors)
.await
.map(local_mutation_outcome)
.map_err(data_error)
.map_err(data_error)?;
Ok(self.finish_billing_model_context_mutation(outcome))
}
pub(crate) async fn find_payment_gateway_config(
@@ -549,3 +583,135 @@ impl AppState {
self.find_user_daily_quota_availability(user_id).await
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use serde_json::json;
use super::{
AdminBillingCollectorWriteInput, AdminBillingRuleWriteInput, AppState, LocalMutationOutcome,
};
const CACHE_KEY: &str = "billing-mutation-test";
fn rule_input() -> AdminBillingRuleWriteInput {
AdminBillingRuleWriteInput {
name: "chat-input".to_string(),
task_type: "chat".to_string(),
global_model_id: Some("global-model".to_string()),
model_id: Some("model-1".to_string()),
expression: "input_tokens * 0.01".to_string(),
variables: json!({"base": 1}),
dimension_mappings: json!({"input_tokens": "input_tokens"}),
is_enabled: true,
}
}
fn collector_input(dimension_name: &str) -> AdminBillingCollectorWriteInput {
AdminBillingCollectorWriteInput {
api_format: "openai".to_string(),
task_type: "chat".to_string(),
dimension_name: dimension_name.to_string(),
source_type: "request".to_string(),
source_path: Some("usage.input_tokens".to_string()),
value_type: "float".to_string(),
transform_expression: None,
default_value: None,
priority: 10,
is_enabled: true,
}
}
fn prime_cache(state: &AppState) {
state.auth_request_cost_upper_bound_cache.insert(
CACHE_KEY.to_string(),
Some(1.0),
Duration::from_secs(60),
);
assert!(state
.auth_request_cost_upper_bound_cache
.get(&CACHE_KEY.to_string(), Duration::from_secs(60),)
.is_some());
}
fn assert_cache_cleared(state: &AppState) {
assert_eq!(
state
.auth_request_cost_upper_bound_cache
.get(&CACHE_KEY.to_string(), Duration::from_secs(60),),
None
);
}
#[tokio::test]
async fn applied_admin_billing_mutations_clear_auth_cost_cache() {
let state = AppState::new().expect("app state should build");
prime_cache(&state);
let created_rule = state
.create_admin_billing_rule(&rule_input())
.await
.expect("rule create should succeed");
let rule_id = match created_rule {
LocalMutationOutcome::Applied(record) => record.id,
other => panic!("expected applied rule create, got {other:?}"),
};
assert_cache_cleared(&state);
prime_cache(&state);
let updated_rule = state
.update_admin_billing_rule(&rule_id, &rule_input())
.await
.expect("rule update should succeed");
assert!(matches!(updated_rule, LocalMutationOutcome::Applied(_)));
assert_cache_cleared(&state);
prime_cache(&state);
let created_collector = state
.create_admin_billing_collector(&collector_input("latency"))
.await
.expect("collector create should succeed");
let collector_id = match created_collector {
LocalMutationOutcome::Applied(record) => record.id,
other => panic!("expected applied collector create, got {other:?}"),
};
assert_cache_cleared(&state);
prime_cache(&state);
let updated_collector = state
.update_admin_billing_collector(&collector_id, &collector_input("latency"))
.await
.expect("collector update should succeed");
assert!(matches!(
updated_collector,
LocalMutationOutcome::Applied(_)
));
assert_cache_cleared(&state);
prime_cache(&state);
let applied_preset = state
.apply_admin_billing_preset("test", "merge", &[collector_input("images")])
.await
.expect("preset apply should succeed");
assert!(matches!(applied_preset, LocalMutationOutcome::Applied(_)));
assert_cache_cleared(&state);
}
#[tokio::test]
async fn non_applied_admin_billing_mutation_keeps_auth_cost_cache() {
let state = AppState::new().expect("app state should build");
prime_cache(&state);
let outcome = state
.update_admin_billing_rule("missing", &rule_input())
.await
.expect("missing rule update should return a local outcome");
assert_eq!(outcome, LocalMutationOutcome::NotFound);
assert!(state
.auth_request_cost_upper_bound_cache
.get(&CACHE_KEY.to_string(), Duration::from_secs(60),)
.is_some());
}
}
@@ -127,6 +127,44 @@ impl AppState {
.await
.map_err(|err| GatewayError::Internal(err.to_string()))
}
/// Persist a candidate status when the caller does not need the materialized row.
///
/// Lifecycle updates are emitted on the hot path (in particular the first-byte
/// `pending -> streaming` transition). Rebuilding `StoredRequestCandidate` here
/// only to discard it adds validation and clones for every update, especially when
/// the async queue is enabled.
pub(crate) async fn enqueue_request_candidate_status(
&self,
candidate: candidates::UpsertRequestCandidateRecord,
) -> Result<Option<()>, GatewayError> {
if let Some(queue) = self.request_candidate_queue.as_ref() {
queue
.enqueue_or_fallback(candidate)
.await
.map_err(|err| GatewayError::Internal(err.to_string()))?;
return Ok(Some(()));
}
self.data
.upsert_request_candidate(candidate)
.await
.map(|stored| stored.map(|_| ()))
.map_err(|err| GatewayError::Internal(err.to_string()))
}
/// Try the in-memory lifecycle lane without awaiting or touching the repository.
/// The returned record must be persisted through `enqueue_request_candidate_status`
/// when the queue is disabled or closed.
pub(crate) fn try_enqueue_request_candidate_status(
&self,
candidate: candidates::UpsertRequestCandidateRecord,
) -> Result<(), candidates::UpsertRequestCandidateRecord> {
let Some(queue) = self.request_candidate_queue.as_ref() else {
return Err(candidate);
};
queue.try_enqueue_priority_status(candidate)
}
}
fn stored_request_candidate_from_upsert(
@@ -1844,7 +1844,7 @@ fn usage_repositories_are_owned_by_contracts_and_driver_adapters() {
"sql"
)
.len(),
26,
27,
"all PostgreSQL usage SQL fragments should be owned by the adapter crate"
);
}
@@ -36,7 +36,9 @@ use super::super::{
use crate::admin_api::{
maybe_build_local_admin_provider_oauth_response, AdminAppState, AdminRequestContext,
};
use crate::ai_serving::{provider_key_pool_score_id, provider_key_pool_score_scope};
use crate::ai_serving::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
use crate::audit::AdminAuditEvent;
use crate::constants::{
GATEWAY_HEADER, TRUSTED_ADMIN_MANAGEMENT_TOKEN_ID_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER,
@@ -1982,6 +1984,14 @@ async fn gateway_handles_admin_provider_oauth_start_key_locally_with_trusted_adm
"oauth-access-token",
);
key.auth_type = "oauth".to_string();
key.is_active = false;
key.error_count = Some(7);
key.health_by_format = Some(json!({
"openai:chat": {"consecutive_failures": 3}
}));
key.circuit_breaker_by_format = Some(json!({
"openai:chat": {"state": "open"}
}));
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
@@ -2982,6 +2992,7 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
let mut provider = sample_provider("provider-codex", "codex", 10);
provider.provider_type = "codex".to_string();
provider.config = Some(json!({"pool_advanced": {}}));
let mut key = sample_key(
"key-codex-oauth",
@@ -2990,12 +3001,35 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
"__placeholder__",
);
key.auth_type = "oauth".to_string();
key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
key.oauth_invalid_reason = Some("[ACCOUNT_BLOCK] token invalid".to_string());
key.error_count = Some(7);
key.health_by_format = Some(json!({
"openai:chat": {"consecutive_failures": 3}
}));
key.circuit_breaker_by_format = Some(json!({
"openai:chat": {"state": "open"}
}));
let score_identity = PoolMemberIdentity::provider_api_key("provider-codex", "key-codex-oauth");
let score_scope = provider_key_pool_score_scope();
let invalid_score = build_provider_key_pool_score_upsert(
&key,
"codex",
None,
1_700_000_000,
aether_pool_core::PoolMemberScoreRules::default(),
)
.into_stored();
assert_eq!(invalid_score.hard_state, PoolMemberHardState::AuthInvalid);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![],
vec![key],
));
let pool_score_repository =
Arc::new(InMemoryPoolMemberScoreRepository::seed(vec![invalid_score]));
let (upstream_url, upstream_handle) = start_server(upstream).await;
let (token_url, token_handle) = start_server(token_server).await;
@@ -3006,6 +3040,7 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
GatewayDataState::with_provider_catalog_repository_for_tests(
provider_catalog_repository.clone(),
)
.with_pool_score_repository_for_tests(Arc::clone(&pool_score_repository))
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY),
)
.with_provider_oauth_state_entry_for_tests(
@@ -3070,6 +3105,23 @@ async fn gateway_completes_admin_provider_oauth_key_locally_with_trusted_admin_p
.expect("keys should load");
let persisted = reloaded.first().expect("persisted key should exist");
assert_eq!(persisted.expires_at_unix_secs, Some(4_102_444_800));
assert!(persisted.is_active);
assert_eq!(persisted.oauth_invalid_at_unix_secs, None);
assert_eq!(persisted.oauth_invalid_reason, None);
assert_eq!(persisted.error_count, Some(0));
assert_eq!(persisted.health_by_format, Some(json!({})));
assert_eq!(persisted.circuit_breaker_by_format, Some(json!({})));
let scores = pool_score_repository
.get_pool_member_scores_by_ids(&GetPoolMemberScoresByIdsQuery {
ids: vec![provider_key_pool_score_id(&score_identity, &score_scope)],
})
.await
.expect("pool score should load");
assert_eq!(scores.len(), 1);
assert!(
scores[0].hard_state.schedulable(),
"OAuth completion should replace AuthInvalid with a schedulable score"
);
let decrypted_api_key = decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
persisted
@@ -4201,19 +4253,16 @@ async fn gateway_import_invalidate_cached_oauth_entry_before_followup_resolution
cached_entry.auth_header_value,
"Bearer cached-old-codex-access-token"
);
let mut replaceable_key = provider_catalog_repository
.list_keys_by_ids(&["key-codex-import-cache-duplicate".to_string()])
.await
.expect("keys should load")
.into_iter()
.next()
.expect("key should exist");
replaceable_key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
replaceable_key.oauth_invalid_reason = Some("[OAUTH_EXPIRED] token invalidated".to_string());
provider_catalog_repository
.update_key(&replaceable_key)
.update_key_oauth_runtime_state(
"key-codex-import-cache-duplicate",
Some(1_700_000_000),
Some("[OAUTH_EXPIRED] token invalidated"),
None,
Some(1_700_000_000),
)
.await
.expect("key should update");
.expect("oauth invalid marker should be seeded through the runtime mutation");
let gateway = build_router_with_state(app_state.clone());
let (gateway_url, gateway_handle) = start_server(gateway).await;
@@ -3566,8 +3566,10 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
first_key.name = "alpha".to_string();
first_key.auto_fetch_models = true;
first_key.allowed_models = Some(json!(["legacy-model"]));
first_key.learned_rpm_limit = Some(18);
let mut second_key = sample_key("key-openai-b", "provider-openai", "openai:chat", "sk-b");
second_key.name = "beta".to_string();
second_key.learned_rpm_limit = Some(24);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
Vec::new(),
@@ -3619,6 +3621,7 @@ async fn gateway_batch_updates_shared_pool_key_configuration() {
assert_eq!(key.api_formats, Some(json!(["openai:responses"])));
assert_eq!(key.internal_priority, 7);
assert_eq!(key.rpm_limit, None);
assert_eq!(key.learned_rpm_limit, None);
assert!(!key.auto_fetch_models);
assert_eq!(
key.allowed_models,
@@ -14,6 +14,7 @@ use aether_data::repository::global_models::InMemoryGlobalModelReadRepository;
use aether_data::repository::oauth_providers::{
InMemoryOAuthProviderRepository, OAuthProviderReadRepository, StoredOAuthProviderConfig,
};
use aether_data::repository::pool_scores::InMemoryPoolMemberScoreRepository;
use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository;
use aether_data::repository::users::{StoredUserAuthRecord, UserReadRepository};
use aether_data::repository::wallet::{StoredWalletSnapshot, WalletLookupKey};
@@ -21,6 +22,10 @@ use aether_data_contracts::repository::global_models::{
AdminGlobalModelListQuery, AdminProviderModelListQuery, GlobalModelReadRepository,
StoredPublicGlobalModel,
};
use aether_data_contracts::repository::pool_scores::{
GetPoolMemberScoresByIdsQuery, PoolMemberHardState, PoolMemberIdentity, PoolMemberProbeStatus,
PoolScoreReadRepository,
};
use aether_data_contracts::repository::provider_catalog::ProviderCatalogReadRepository;
use axum::body::{Body, Bytes};
use axum::http::HeaderMap;
@@ -33,6 +38,9 @@ use super::super::helpers::{hash_api_key, sample_endpoint, sample_key, sample_pr
use super::super::{
build_router_with_state, build_state_with_execution_runtime_override, start_server, AppState,
};
use crate::ai_serving::{
build_provider_key_pool_score_upsert, provider_key_pool_score_id, provider_key_pool_score_scope,
};
use crate::constants::{
GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER,
TRUSTED_ADMIN_USER_ROLE_HEADER,
@@ -227,6 +235,7 @@ fn sample_oauth_system_import_payload(access_token: &str, refresh_token: &str) -
refresh_token
),
"api_formats": ["openai:responses"],
"rpm_limit": null,
"is_active": true
}],
"models": []
@@ -1960,11 +1969,10 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
let gateway = build_router_with_state(
AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state),
);
let state = AppState::new()
.expect("gateway should build")
.with_data_state_for_tests(data_state);
let gateway = build_router_with_state(state.clone());
let (gateway_url, gateway_handle) = start_server(gateway).await;
let client = reqwest::Client::new();
@@ -2024,6 +2032,68 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
serde_json::from_str(&auth_config).expect("oauth auth config json should parse");
assert_eq!(auth_config["refresh_token"], "oauth-refresh-token-new");
assert!(state
.update_provider_catalog_key_oauth_runtime_state(
&keys[0].id,
Some(1_700_000_001),
Some("[REFRESH_FAILED] imported token remains invalid"),
None,
Some(1_700_000_001),
)
.await
.expect("invalid marker should be seeded"));
let mut metadata_only_payload = sample_oauth_system_import_payload("unused", "unused");
let key_payload = metadata_only_payload["providers"][0]["api_keys"][0]
.as_object_mut()
.expect("OAuth key payload should be an object");
key_payload.remove("api_key");
key_payload.remove("auth_config");
key_payload.insert("internal_priority".to_string(), json!(71));
let response = client
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&metadata_only_payload)
.send()
.await
.expect("metadata-only import should succeed");
assert_eq!(response.status(), StatusCode::OK);
let keys = provider_catalog_repository
.list_keys_by_provider_ids(std::slice::from_ref(&providers[0].id))
.await
.expect("keys should reload");
assert_eq!(keys[0].internal_priority, 71);
assert_eq!(keys[0].oauth_invalid_at_unix_secs, Some(1_700_000_001));
assert_eq!(
keys[0].oauth_invalid_reason.as_deref(),
Some("[REFRESH_FAILED] imported token remains invalid")
);
let response = client
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&sample_oauth_system_import_payload(
"oauth-access-token-new",
"oauth-refresh-token-new",
))
.send()
.await
.expect("same valid credentials should be accepted as recovery input");
assert_eq!(response.status(), StatusCode::OK);
let keys = provider_catalog_repository
.list_keys_by_provider_ids(std::slice::from_ref(&providers[0].id))
.await
.expect("keys should reload after credential recovery");
assert_eq!(keys[0].oauth_invalid_at_unix_secs, None);
assert_eq!(keys[0].oauth_invalid_reason, None);
gateway_handle.abort();
}
@@ -2058,6 +2128,7 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
let mut provider = sample_provider("provider-codex-existing", "oauth-import-provider", 10);
provider.provider_type = "codex".to_string();
provider.config = Some(json!({"pool_advanced": {}}));
let endpoint = sample_endpoint(
"endpoint-codex-existing",
"provider-codex-existing",
@@ -2073,9 +2144,17 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
existing_key.name = "oauth-primary".to_string();
existing_key.auth_type = "oauth".to_string();
existing_key.expires_at_unix_secs = Some(1);
existing_key.learned_rpm_limit = Some(31);
existing_key.oauth_invalid_at_unix_secs = Some(1_700_000_000);
existing_key.oauth_invalid_reason =
Some("[REFRESH_FAILED] refresh_token 无效、已过期或已撤销,请重新登录授权".to_string());
existing_key.error_count = Some(7);
existing_key.health_by_format = Some(json!({
"openai:responses": {"consecutive_failures": 3}
}));
existing_key.circuit_breaker_by_format = Some(json!({
"openai:responses": {"state": "open"}
}));
existing_key.encrypted_auth_config = Some(
encrypt_python_fernet_plaintext(
DEVELOPMENT_ENCRYPTION_KEY,
@@ -2084,11 +2163,30 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
.expect("auth config should encrypt"),
);
let score_identity =
PoolMemberIdentity::provider_api_key("provider-codex-existing", "key-codex-existing");
let score_scope = provider_key_pool_score_scope();
let mut invalid_score = build_provider_key_pool_score_upsert(
&existing_key,
"codex",
None,
1_700_000_000,
aether_pool_core::PoolMemberScoreRules::default(),
)
.into_stored();
invalid_score.last_failure_at = Some(1_700_000_000);
invalid_score.failure_count = 9;
invalid_score.last_probe_failure_at = Some(1_700_000_000);
invalid_score.probe_failure_count = 4;
invalid_score.probe_status = PoolMemberProbeStatus::Failed;
assert_eq!(invalid_score.hard_state, PoolMemberHardState::AuthInvalid);
let provider_catalog_repository = Arc::new(InMemoryProviderCatalogReadRepository::seed(
vec![provider],
vec![endpoint],
vec![existing_key],
));
let pool_score_repository =
Arc::new(InMemoryPoolMemberScoreRepository::seed(vec![invalid_score]));
let global_model_repository = Arc::new(InMemoryGlobalModelReadRepository::seed(Vec::<
StoredPublicGlobalModel,
>::new()));
@@ -2114,6 +2212,7 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
.with_global_model_repository_for_tests(Arc::clone(&global_model_repository))
.attach_auth_module_repository_for_tests(Arc::clone(&auth_module_repository))
.attach_oauth_provider_repository_for_tests(Arc::clone(&oauth_provider_repository))
.with_pool_score_repository_for_tests(Arc::clone(&pool_score_repository))
.with_system_config_values_for_tests(Vec::<(String, Value)>::new())
.with_encryption_key_for_tests(DEVELOPMENT_ENCRYPTION_KEY);
@@ -2125,16 +2224,16 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
);
let (gateway_url, gateway_handle) = start_server(gateway).await;
let mut import_payload =
sample_oauth_system_import_payload("oauth-access-token-new", "oauth-refresh-token-new");
import_payload["providers"][0]["config"] = json!({"pool_advanced": {}});
let response = reqwest::Client::new()
.post(format!("{gateway_url}/api/admin/system/config/import"))
.header(GATEWAY_HEADER, "rust-phase3b")
.header(TRUSTED_ADMIN_USER_ID_HEADER, "admin-user-123")
.header(TRUSTED_ADMIN_USER_ROLE_HEADER, "admin")
.header(TRUSTED_ADMIN_SESSION_ID_HEADER, "session-123")
.json(&sample_oauth_system_import_payload(
"oauth-access-token-new",
"oauth-refresh-token-new",
))
.json(&import_payload)
.send()
.await
.expect("request should succeed");
@@ -2159,6 +2258,10 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
assert_eq!(key.oauth_invalid_at_unix_secs, None);
assert_eq!(key.oauth_invalid_reason, None);
assert_eq!(key.expires_at_unix_secs, None);
assert_eq!(key.learned_rpm_limit, None);
assert_eq!(key.error_count, Some(0));
assert_eq!(key.health_by_format, Some(json!({})));
assert_eq!(key.circuit_breaker_by_format, Some(json!({})));
assert_eq!(
decrypt_python_fernet_ciphertext(
DEVELOPMENT_ENCRYPTION_KEY,
@@ -2187,6 +2290,24 @@ async fn gateway_overwrites_oauth_provider_key_credentials_from_admin_system_imp
assert!(auth_config.get("token_type").is_none());
assert!(auth_config.get("expires_at").is_none());
let scores = pool_score_repository
.get_pool_member_scores_by_ids(&GetPoolMemberScoresByIdsQuery {
ids: vec![provider_key_pool_score_id(&score_identity, &score_scope)],
})
.await
.expect("pool score should load");
assert_eq!(scores.len(), 1);
assert!(
scores[0].hard_state.schedulable(),
"OAuth credential import should reset the pool score: {:?}",
scores[0]
);
assert_eq!(scores[0].last_failure_at, None);
assert_eq!(scores[0].failure_count, 0);
assert_eq!(scores[0].last_probe_failure_at, None);
assert_eq!(scores[0].probe_failure_count, 0);
assert_eq!(scores[0].probe_status, PoolMemberProbeStatus::Never);
gateway_handle.abort();
refresh_handle.abort();
}