mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-07 01:47:47 +08:00
Improve gateway scheduling and runtime admission
This commit is contained in:
@@ -1748,11 +1748,15 @@ impl GatewayDataState {
|
||||
api_key_id: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<GatewayAuthApiKeySnapshot>, DataLayerError> {
|
||||
let snapshot = read_resolved_auth_api_key_snapshot_by_user_api_key_ids(
|
||||
self,
|
||||
user_id,
|
||||
api_key_id,
|
||||
now_unix_secs,
|
||||
let snapshot = crate::request_diagnostics::observe_db_operation(
|
||||
"auth_api_key_snapshot",
|
||||
self.database_pool_summary(),
|
||||
read_resolved_auth_api_key_snapshot_by_user_api_key_ids(
|
||||
self,
|
||||
user_id,
|
||||
api_key_id,
|
||||
now_unix_secs,
|
||||
),
|
||||
)
|
||||
.await?;
|
||||
self.apply_user_group_effective_policies(snapshot).await
|
||||
@@ -1763,8 +1767,12 @@ impl GatewayDataState {
|
||||
key_hash: &str,
|
||||
now_unix_secs: u64,
|
||||
) -> Result<Option<GatewayAuthApiKeySnapshot>, DataLayerError> {
|
||||
let snapshot =
|
||||
read_resolved_auth_api_key_snapshot_by_key_hash(self, key_hash, now_unix_secs).await?;
|
||||
let snapshot = crate::request_diagnostics::observe_db_operation(
|
||||
"auth_api_key_snapshot_by_hash",
|
||||
self.database_pool_summary(),
|
||||
read_resolved_auth_api_key_snapshot_by_key_hash(self, key_hash, now_unix_secs),
|
||||
)
|
||||
.await?;
|
||||
self.apply_user_group_effective_policies(snapshot).await
|
||||
}
|
||||
|
||||
@@ -1782,7 +1790,13 @@ impl GatewayDataState {
|
||||
let Some(repository) = self.user_reader.as_ref() else {
|
||||
return Ok(Some(snapshot));
|
||||
};
|
||||
let Some(user) = repository.find_user_auth_by_id(&snapshot.user_id).await? else {
|
||||
let Some(user) = crate::request_diagnostics::observe_db_operation(
|
||||
"auth_user_policy",
|
||||
self.database_pool_summary(),
|
||||
repository.find_user_auth_by_id(&snapshot.user_id),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
return Ok(Some(snapshot));
|
||||
};
|
||||
if user.role.eq_ignore_ascii_case("admin") && !snapshot.api_key_is_standalone {
|
||||
|
||||
@@ -107,10 +107,17 @@ impl GatewayDataState {
|
||||
&self,
|
||||
candidate: UpsertRequestCandidateRecord,
|
||||
) -> Result<Option<StoredRequestCandidate>, DataLayerError> {
|
||||
match &self.request_candidate_writer {
|
||||
Some(repository) => repository.upsert(candidate).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"request_candidate_upsert",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.request_candidate_writer {
|
||||
Some(repository) => repository.upsert(candidate).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn delete_request_candidates_created_before(
|
||||
|
||||
@@ -3,17 +3,77 @@ 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::sync::{Arc, OnceLock};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::Notify;
|
||||
|
||||
use super::{GatewayDataConfig, GatewayDataState, StoredSystemConfigEntry};
|
||||
|
||||
const SYSTEM_CONFIG_VALUE_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
|
||||
fn system_config_value_load_guard() -> &'static Mutex<()> {
|
||||
static GUARD: OnceLock<Mutex<()>> = OnceLock::new();
|
||||
GUARD.get_or_init(|| Mutex::new(()))
|
||||
fn 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,
|
||||
}
|
||||
|
||||
enum SystemConfigValueLoadRegistration<'a> {
|
||||
Leader(SystemConfigValueLoadGuard<'a>),
|
||||
Follower,
|
||||
Bypass,
|
||||
}
|
||||
|
||||
struct SystemConfigValueLoadGuard<'a> {
|
||||
state: &'a SystemConfigValueLoadState,
|
||||
key: Option<String>,
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn current_system_config_updated_at_unix_secs() -> u64 {
|
||||
@@ -447,27 +507,69 @@ impl GatewayDataState {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
let _guard = system_config_value_load_guard().lock().await;
|
||||
let cached_value = self
|
||||
.system_config_value_cache
|
||||
.read()
|
||||
.expect("system config value cache lock")
|
||||
.get(key)
|
||||
.cloned();
|
||||
if let Some((cached_at, value)) = cached_value {
|
||||
if cached_at.elapsed() <= SYSTEM_CONFIG_VALUE_CACHE_TTL {
|
||||
return Ok(value);
|
||||
let 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);
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
return Ok(value);
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some(backends) = self.backends.as_ref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let value = backends.find_system_config_value(key).await?;
|
||||
self.system_config_value_cache
|
||||
.write()
|
||||
.expect("system config value cache lock")
|
||||
.insert(key.to_string(), (Instant::now(), value.clone()));
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(crate) async fn upsert_system_config_value(
|
||||
|
||||
@@ -197,9 +197,7 @@ pub(crate) struct GatewayDataState {
|
||||
settlement_writer: Option<Arc<dyn SettlementWriteRepository>>,
|
||||
system_config_values: Option<Arc<RwLock<BTreeMap<String, StoredSystemConfigEntry>>>>,
|
||||
system_config_value_cache: Arc<RwLock<BTreeMap<String, (Instant, Option<serde_json::Value>)>>>,
|
||||
billing_model_context_cache: Arc<
|
||||
RwLock<HashMap<BillingModelContextCacheKey, (Instant, Option<StoredBillingModelContext>)>>,
|
||||
>,
|
||||
billing_model_context_cache: Arc<BillingModelContextCacheState>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
|
||||
@@ -216,6 +214,15 @@ 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,
|
||||
}
|
||||
|
||||
impl fmt::Debug for GatewayDataState {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GatewayDataState")
|
||||
|
||||
@@ -15,14 +15,24 @@ impl GatewayDataState {
|
||||
api_format: &str,
|
||||
global_model_name: &str,
|
||||
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.list_for_exact_api_format_and_global_model(api_format, global_model_name)
|
||||
.await
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"candidate_selection",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.list_for_exact_api_format_and_global_model(
|
||||
api_format,
|
||||
global_model_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_minimal_candidate_selection_rows_for_requested_model(
|
||||
@@ -30,38 +40,62 @@ impl GatewayDataState {
|
||||
api_format: &str,
|
||||
requested_model_name: &str,
|
||||
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.list_for_exact_api_format_and_requested_model(api_format, requested_model_name)
|
||||
.await
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"candidate_selection",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.list_for_exact_api_format_and_requested_model(
|
||||
api_format,
|
||||
requested_model_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_minimal_candidate_selection_rows_for_requested_model_page(
|
||||
&self,
|
||||
query: &StoredRequestedModelCandidateRowsQuery,
|
||||
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.list_for_exact_api_format_and_requested_model_page(query)
|
||||
.await
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"candidate_selection",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => {
|
||||
repository
|
||||
.list_for_exact_api_format_and_requested_model_page(query)
|
||||
.await
|
||||
}
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_minimal_candidate_selection_rows_for_api_format(
|
||||
&self,
|
||||
api_format: &str,
|
||||
) -> Result<Vec<StoredMinimalCandidateSelectionRow>, DataLayerError> {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => repository.list_for_exact_api_format(api_format).await,
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"candidate_selection",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.minimal_candidate_selection_reader {
|
||||
Some(repository) => repository.list_for_exact_api_format(api_format).await,
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn list_pool_key_candidate_rows_for_group(
|
||||
|
||||
@@ -43,6 +43,7 @@ use aether_data_contracts::repository::usage::{
|
||||
use aether_runtime_state::RuntimeQueueStore;
|
||||
use aether_video_tasks_core::read_data_backed_video_task_response;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::time::timeout;
|
||||
|
||||
fn normalize_billing_context_cache_part(value: &str) -> String {
|
||||
value.trim().to_string()
|
||||
@@ -55,12 +56,51 @@ fn normalize_optional_billing_context_cache_part(value: Option<&str>) -> Option<
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
enum BillingModelContextInflightRegistration {
|
||||
Leader(u64),
|
||||
Follower,
|
||||
Bypass,
|
||||
}
|
||||
|
||||
struct BillingModelContextInflightGuard<'a> {
|
||||
state: &'a GatewayDataState,
|
||||
key: Option<BillingModelContextCacheKey>,
|
||||
token: u64,
|
||||
}
|
||||
|
||||
impl<'a> BillingModelContextInflightGuard<'a> {
|
||||
fn new(state: &'a GatewayDataState, key: BillingModelContextCacheKey, token: u64) -> Self {
|
||||
Self {
|
||||
state,
|
||||
key: Some(key),
|
||||
token,
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self) {
|
||||
if let Some(key) = self.key.take() {
|
||||
self.state
|
||||
.finish_billing_model_context_inflight(&key, self.token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BillingModelContextInflightGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.finish();
|
||||
}
|
||||
}
|
||||
|
||||
impl GatewayDataState {
|
||||
const MAINTENANCE_POOL_IDLE_RESERVE_ENV: &'static str =
|
||||
"AETHER_GATEWAY_MAINTENANCE_POOL_IDLE_RESERVE";
|
||||
const MAINTENANCE_POOL_PRESSURE_MAX_DEFER: Duration = Duration::from_secs(30);
|
||||
const BILLING_MODEL_CONTEXT_CACHE_TTL: Duration = Duration::from_secs(30);
|
||||
const BILLING_MODEL_CONTEXT_CACHE_MAX_ENTRIES: usize = 4096;
|
||||
#[cfg(not(test))]
|
||||
const BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
#[cfg(test)]
|
||||
const BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
pub(crate) async fn run_database_maintenance(
|
||||
&self,
|
||||
@@ -1041,10 +1081,17 @@ impl GatewayDataState {
|
||||
&self,
|
||||
usage: UpsertUsageRecord,
|
||||
) -> Result<Option<StoredRequestUsageAudit>, DataLayerError> {
|
||||
match &self.usage_writer {
|
||||
Some(repository) => repository.upsert(usage).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"usage_upsert",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.usage_writer {
|
||||
Some(repository) => repository.upsert(usage).await.map(Some),
|
||||
None => Ok(None),
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
@@ -1710,17 +1757,47 @@ impl GatewayDataState {
|
||||
if let Some(value) = self.cached_billing_model_context(&key) {
|
||||
return Ok(value);
|
||||
}
|
||||
match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
let value = repository
|
||||
.find_model_context(provider_id, provider_api_key_id, global_model_name)
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
Ok(None)
|
||||
loop {
|
||||
let notified = self.billing_model_context_cache.inflight_notify.notified();
|
||||
match self.register_billing_model_context_inflight(&key) {
|
||||
BillingModelContextInflightRegistration::Bypass => {
|
||||
return self
|
||||
.load_billing_model_context_by_name(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
global_model_name,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
BillingModelContextInflightRegistration::Follower => {
|
||||
if timeout(
|
||||
Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT,
|
||||
notified,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
self.expire_billing_model_context_inflight(&key);
|
||||
}
|
||||
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 result = self
|
||||
.load_billing_model_context_by_name(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
global_model_name,
|
||||
)
|
||||
.await;
|
||||
guard.finish();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1739,18 +1816,164 @@ impl GatewayDataState {
|
||||
if let Some(value) = self.cached_billing_model_context(&key) {
|
||||
return Ok(value);
|
||||
}
|
||||
match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
let value = repository
|
||||
.find_model_context_by_model_id(provider_id, provider_api_key_id, model_id)
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
Ok(value)
|
||||
loop {
|
||||
let notified = self.billing_model_context_cache.inflight_notify.notified();
|
||||
match self.register_billing_model_context_inflight(&key) {
|
||||
BillingModelContextInflightRegistration::Bypass => {
|
||||
return self
|
||||
.load_billing_model_context_by_model_id(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
model_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
BillingModelContextInflightRegistration::Follower => {
|
||||
if timeout(
|
||||
Self::BILLING_MODEL_CONTEXT_CACHE_INFLIGHT_WAIT_TIMEOUT,
|
||||
notified,
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
self.expire_billing_model_context_inflight(&key);
|
||||
}
|
||||
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 result = self
|
||||
.load_billing_model_context_by_model_id(
|
||||
key,
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
model_id,
|
||||
)
|
||||
.await;
|
||||
guard.finish();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn load_billing_model_context_by_name(
|
||||
&self,
|
||||
key: BillingModelContextCacheKey,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"billing_model_context",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
let value = repository
|
||||
.find_model_context(provider_id, provider_api_key_id, global_model_name)
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn load_billing_model_context_by_model_id(
|
||||
&self,
|
||||
key: BillingModelContextCacheKey,
|
||||
provider_id: &str,
|
||||
provider_api_key_id: Option<&str>,
|
||||
model_id: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, DataLayerError> {
|
||||
crate::request_diagnostics::observe_db_operation(
|
||||
"billing_model_context",
|
||||
self.database_pool_summary(),
|
||||
async {
|
||||
match &self.billing_reader {
|
||||
Some(repository) => {
|
||||
let value = repository
|
||||
.find_model_context_by_model_id(
|
||||
provider_id,
|
||||
provider_api_key_id,
|
||||
model_id,
|
||||
)
|
||||
.await?;
|
||||
self.remember_billing_model_context(key, value.clone());
|
||||
Ok(value)
|
||||
}
|
||||
None => {
|
||||
self.remember_billing_model_context(key, None);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 {
|
||||
tracing::warn!(
|
||||
event_name = "billing_model_context_cache_inflight_expired",
|
||||
log_type = "ops",
|
||||
cache_key = ?key,
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1759,6 +1982,7 @@ impl GatewayDataState {
|
||||
key: &BillingModelContextCacheKey,
|
||||
) -> Option<Option<StoredBillingModelContext>> {
|
||||
self.billing_model_context_cache
|
||||
.entries
|
||||
.read()
|
||||
.expect("billing model context cache lock")
|
||||
.get(key)
|
||||
@@ -1775,6 +1999,7 @@ impl GatewayDataState {
|
||||
) {
|
||||
let mut cache = self
|
||||
.billing_model_context_cache
|
||||
.entries
|
||||
.write()
|
||||
.expect("billing model context cache lock");
|
||||
cache.retain(|_, (cached_at, _)| {
|
||||
@@ -1794,9 +2019,25 @@ impl GatewayDataState {
|
||||
|
||||
fn clear_billing_model_context_cache(&self) {
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn admin_billing_enabled_default_value_exists(
|
||||
@@ -2211,12 +2452,89 @@ impl GatewayDataState {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use aether_data::repository::users::{InMemoryUserReadRepository, StoredUserExportRow};
|
||||
use aether_data_contracts::repository::billing::{
|
||||
BillingReadRepository, StoredBillingModelContext,
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use serde_json::json;
|
||||
|
||||
use super::GatewayDataState;
|
||||
|
||||
struct SlowBillingContextRepository {
|
||||
calls: AtomicUsize,
|
||||
context: StoredBillingModelContext,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillingReadRepository for SlowBillingContextRepository {
|
||||
async fn find_model_context(
|
||||
&self,
|
||||
_provider_id: &str,
|
||||
_provider_api_key_id: Option<&str>,
|
||||
_global_model_name: &str,
|
||||
) -> Result<Option<StoredBillingModelContext>, aether_data_contracts::DataLayerError>
|
||||
{
|
||||
self.calls.fetch_add(1, Ordering::AcqRel);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
Ok(Some(self.context.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
fn billing_context() -> StoredBillingModelContext {
|
||||
StoredBillingModelContext::new(
|
||||
"provider-1".to_string(),
|
||||
Some("pay_as_you_go".to_string()),
|
||||
Some("key-1".to_string()),
|
||||
None,
|
||||
None,
|
||||
"global-model-1".to_string(),
|
||||
"gpt-5".to_string(),
|
||||
None,
|
||||
Some(0.02),
|
||||
Some(json!({"tiers":[{"up_to":null,"input_price_per_1m":3.0,"output_price_per_1m":15.0}]})),
|
||||
Some("model-1".to_string()),
|
||||
Some("gpt-5-upstream".to_string()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("billing context should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn billing_model_context_cache_coalesces_concurrent_loads() {
|
||||
let repository = Arc::new(SlowBillingContextRepository {
|
||||
calls: AtomicUsize::new(0),
|
||||
context: billing_context(),
|
||||
});
|
||||
let state = Arc::new(GatewayDataState::with_billing_reader_for_tests(
|
||||
repository.clone(),
|
||||
));
|
||||
let mut tasks = Vec::new();
|
||||
|
||||
for _ in 0..16 {
|
||||
let state = Arc::clone(&state);
|
||||
tasks.push(tokio::spawn(async move {
|
||||
state
|
||||
.find_billing_model_context("provider-1", Some("key-1"), "gpt-5")
|
||||
.await
|
||||
.expect("billing context lookup should succeed")
|
||||
.expect("billing context should exist");
|
||||
}));
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
task.await.expect("lookup task should complete");
|
||||
}
|
||||
|
||||
assert_eq!(repository.calls.load(Ordering::Acquire), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_non_admin_export_users_from_user_reader() {
|
||||
let repository = Arc::new(InMemoryUserReadRepository::seed_export_users(vec![
|
||||
|
||||
Reference in New Issue
Block a user