mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-13 22:50:19 +08:00
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:
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+1013
-76
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -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<
|
||||
|
||||
Vendored
+3
-1
@@ -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
@@ -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")))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("a_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());
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+12
-5
@@ -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(¤t);
|
||||
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
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
¤t_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(
|
||||
¤t_key,
|
||||
effect.classification,
|
||||
effect.status_code,
|
||||
current_rpm,
|
||||
effect.headers,
|
||||
observed_at_unix_secs,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let expected = ProviderCatalogKeyAdaptiveState::from(¤t_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(¤t_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(¤t_key, current_rpm, observed_at_unix_secs)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let expected = ProviderCatalogKeyAdaptiveState::from(¤t_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(¤t_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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user