mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(store): materialize quota_limit_reached for single-source limit status
Dashboard reads the materialized column instead of parsing raw quota JSON (design §3.3/§5.3): Provider computes snapshot-level limit_reached on quota writes, store persists it, Admin account_status and Dashboard both consume the same value. - QuotaObservation gains limit_reached; openai writes fact.exhausted() (429 always true), xai writes used_percent>=100 - compare_and_swap_provider_quota updates the column; get_quotas and ACCOUNT_SELECT read it back - ProviderAccountSummary carries quota_limit_reached; admin_account_status derives RateLimited from it (Unknown -> Invalid) - Dashboard provider_account_metrics reads quota_limit_reached instead of provider_quota_json->'rate_limit' - Migration 0003 adds the column with backfill (coalesce for NULL when rate_limit key is absent); 0004 fixes NULL backfill for already-applied environments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -578,6 +578,8 @@ pub struct QuotaObservation {
|
||||
pub expected_revision: CredentialRevision,
|
||||
pub quota: Option<OpaqueProviderData>,
|
||||
pub observed_at: Option<SystemTime>,
|
||||
/// 快照级限流事实(Provider 解析后物化);None 表示不更新物化列。
|
||||
pub limit_reached: Option<bool>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for QuotaObservation {
|
||||
|
||||
@@ -348,7 +348,7 @@ pub(crate) async fn provider_account_metrics(
|
||||
) -> StoreResult<ProviderAccountMetrics> {
|
||||
let row = sqlx::query(
|
||||
"with normalized as (
|
||||
select enabled, availability, provider_quota_json,
|
||||
select enabled, availability, quota_limit_reached,
|
||||
case
|
||||
when not enabled then 'disabled'
|
||||
when availability = 'banned' then 'banned'
|
||||
@@ -357,13 +357,7 @@ pub(crate) async fn provider_account_metrics(
|
||||
when availability = 'expired'
|
||||
or (access_token_expires_at is not null and access_token_expires_at <= $1)
|
||||
then 'expired'
|
||||
when provider_quota_json is not null
|
||||
and provider_quota_json->'rate_limit'->>'limit_reached' = 'true'
|
||||
and (
|
||||
provider_quota_json->'rate_limit'->'primary_window'->>'reset_at' is null
|
||||
or (provider_quota_json->'rate_limit'->'primary_window'->>'reset_at')::bigint > extract(epoch from $1)::bigint
|
||||
)
|
||||
then 'rate_limited'
|
||||
when quota_limit_reached then 'rate_limited'
|
||||
when availability = 'unknown' then 'invalid'
|
||||
else 'active'
|
||||
end as status
|
||||
|
||||
@@ -163,7 +163,8 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
.map(|account| account.as_str().to_owned())
|
||||
.collect::<Vec<_>>();
|
||||
let rows = sqlx::query(
|
||||
"select id, credential_revision, provider_quota_json, quota_observed_at \
|
||||
"select id, credential_revision, provider_quota_json, quota_observed_at, \
|
||||
quota_limit_reached \
|
||||
from provider_accounts \
|
||||
where id = any($1) and provider_quota_json is not null",
|
||||
)
|
||||
@@ -191,12 +192,16 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
let observed_at = row
|
||||
.try_get::<DateTime<Utc>, _>("quota_observed_at")
|
||||
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
|
||||
let limit_reached = row
|
||||
.try_get::<bool, _>("quota_limit_reached")
|
||||
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?;
|
||||
Ok(QuotaObservation {
|
||||
account_id: CoreProviderAccountId::new(account_id)
|
||||
.map_err(|_| CoreStoreError::new(CoreStoreErrorKind::InvalidData))?,
|
||||
expected_revision: revision,
|
||||
quota: Some(quota),
|
||||
observed_at: Some(observed_at.into()),
|
||||
limit_reached: Some(limit_reached),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -223,6 +228,7 @@ impl ProviderAccountStore for PgProviderAccountRepository {
|
||||
Revision::new(observation.expected_revision.get()).map_err(core_store_error)?,
|
||||
quota,
|
||||
observation.observed_at.map(DateTime::<Utc>::from),
|
||||
observation.limit_reached,
|
||||
)
|
||||
.await
|
||||
.map_err(core_store_error)?;
|
||||
|
||||
@@ -72,6 +72,10 @@ pub(crate) fn admin_account_status(
|
||||
.is_some_and(|expires_at| expires_at <= now)
|
||||
{
|
||||
AdminAccountStatus::Expired
|
||||
} else if account.quota_limit_reached {
|
||||
AdminAccountStatus::RateLimited
|
||||
} else if account.availability == AccountAvailability::Unknown {
|
||||
AdminAccountStatus::Invalid
|
||||
} else {
|
||||
AdminAccountStatus::Active
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ pub trait ProviderAccountRepository: Send + Sync {
|
||||
expected_revision: Revision,
|
||||
quota: Option<JsonObject>,
|
||||
observed_at: Option<DateTime<Utc>>,
|
||||
limit_reached: Option<bool>,
|
||||
) -> StoreResult<bool>;
|
||||
async fn delete_provider_account(&self, id: &str) -> StoreResult<bool>;
|
||||
}
|
||||
@@ -94,7 +95,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
upstream_account_id, plan_type, authentication_kind, credential_revision, has_refresh_token,
|
||||
access_token_expires_at, next_refresh_at, enabled, availability,
|
||||
availability_observed_at,
|
||||
quota_observed_at, created_at, updated_at
|
||||
quota_observed_at, quota_limit_reached, created_at, updated_at
|
||||
from provider_accounts
|
||||
where ($1::text is null or provider_kind = $1) and ($2 or enabled)
|
||||
order by provider_kind, name, id",
|
||||
@@ -246,6 +247,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
expected_revision: Revision,
|
||||
quota: Option<JsonObject>,
|
||||
observed_at: Option<DateTime<Utc>>,
|
||||
limit_reached: Option<bool>,
|
||||
) -> StoreResult<bool> {
|
||||
require_nonempty(ENTITY, "account_id", account_id)?;
|
||||
if quota.is_some() != observed_at.is_some() {
|
||||
@@ -257,6 +259,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
let result = sqlx::query(
|
||||
"update provider_accounts
|
||||
set provider_quota_json = $3, quota_observed_at = $4,
|
||||
quota_limit_reached = coalesce($5, quota_limit_reached),
|
||||
updated_at = greatest(now(), coalesce($4, now()))
|
||||
where id = $1 and credential_revision = $2
|
||||
and ($4 is null or quota_observed_at is null or quota_observed_at <= $4)",
|
||||
@@ -265,6 +268,7 @@ impl ProviderAccountRepository for PgProviderAccountRepository {
|
||||
.bind(to_i64(expected_revision.get())?)
|
||||
.bind(quota.map(|value| value.as_value()))
|
||||
.bind(observed_at)
|
||||
.bind(limit_reached)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(|_| postgres_unavailable("compare and swap provider quota"))?;
|
||||
|
||||
@@ -88,6 +88,8 @@ pub struct ProviderAccountSummary {
|
||||
pub availability: AccountAvailability,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
pub quota_observed_at: Option<DateTime<Utc>>,
|
||||
/// 快照级限流事实(Provider 物化)。
|
||||
pub quota_limit_reached: bool,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
@@ -298,6 +300,8 @@ pub struct ProviderAccountObservation {
|
||||
pub provider_quota_json: Option<JsonObject>,
|
||||
pub availability_observed_at: DateTime<Utc>,
|
||||
pub quota_observed_at: Option<DateTime<Utc>>,
|
||||
/// 快照级限流事实(Provider 物化)。
|
||||
pub quota_limit_reached: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -331,14 +335,14 @@ impl ProviderAccountObservation {
|
||||
pub(crate) const ACCOUNT_SELECT: &str = "select id, provider_kind, name, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability,
|
||||
provider_quota_json,
|
||||
provider_quota_json, quota_limit_reached,
|
||||
availability_observed_at, quota_observed_at, created_at, updated_at
|
||||
from provider_accounts where id = $1";
|
||||
|
||||
pub(crate) const ACCOUNT_SELECT_BY_IDS: &str = "select id, provider_kind, name, email, upstream_user_id,
|
||||
upstream_account_id, plan_type, authentication_kind, provider_credentials_json, credential_revision,
|
||||
has_refresh_token, access_token_expires_at, next_refresh_at, enabled, availability,
|
||||
provider_quota_json,
|
||||
provider_quota_json, quota_limit_reached,
|
||||
availability_observed_at, quota_observed_at, created_at, updated_at
|
||||
from provider_accounts
|
||||
where id = any($1::text[]) and provider_kind = $2
|
||||
@@ -440,6 +444,7 @@ pub(crate) fn account_summary_from_row(
|
||||
availability: parse_availability(&availability)?,
|
||||
availability_observed_at: get(&row, "availability_observed_at")?,
|
||||
quota_observed_at: get(&row, "quota_observed_at")?,
|
||||
quota_limit_reached: get(&row, "quota_limit_reached")?,
|
||||
created_at: get(&row, "created_at")?,
|
||||
updated_at: get(&row, "updated_at")?,
|
||||
})
|
||||
|
||||
@@ -156,7 +156,7 @@ async fn connect_and_migrate_should_apply_all_migrations_once_and_reopen_cleanly
|
||||
.expect("drop migration test database");
|
||||
admin.close().await;
|
||||
|
||||
assert_eq!((first_table_count, migration_count), (10, 2));
|
||||
assert_eq!((first_table_count, migration_count), (10, 4));
|
||||
assert_eq!(response_id_types, ["bytea", "bytea"]);
|
||||
assert!(!raw_response_id_index_exists);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ async fn core_quota_batch_reads_only_observed_accounts_in_one_contract_call() {
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
@@ -140,6 +141,7 @@ async fn delayed_provider_observations_cannot_overwrite_newer_state_or_quota() {
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(observed_at),
|
||||
limit_reached: None,
|
||||
};
|
||||
assert_eq!(
|
||||
repository
|
||||
|
||||
@@ -554,6 +554,7 @@ impl CodexCredentialQuotaService {
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(object)),
|
||||
observed_at: Some(observed_at),
|
||||
limit_reached: Some(fact.exhausted()),
|
||||
})
|
||||
.await?;
|
||||
if outcome == QuotaWriteOutcome::Conflict {
|
||||
@@ -626,6 +627,7 @@ impl CodexCredentialQuotaService {
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(quota)),
|
||||
observed_at: Some(observed_at),
|
||||
limit_reached: Some(true),
|
||||
})
|
||||
.await?;
|
||||
if outcome == QuotaWriteOutcome::Conflict {
|
||||
@@ -728,6 +730,7 @@ impl CodexCredentialQuotaService {
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(object)),
|
||||
observed_at: Some(observed_at),
|
||||
limit_reached: Some(true),
|
||||
})
|
||||
.await?
|
||||
== QuotaWriteOutcome::Conflict
|
||||
@@ -870,6 +873,7 @@ impl CodexCredentialQuotaService {
|
||||
expected_revision: account.revision(),
|
||||
quota: Some(OpaqueProviderData::new(object)),
|
||||
observed_at: Some(observed_at),
|
||||
limit_reached: Some(snapshot.fact().exhausted()),
|
||||
})
|
||||
.await?
|
||||
== QuotaWriteOutcome::Conflict
|
||||
|
||||
@@ -471,6 +471,7 @@ async fn openai_admin_provider_projects_codex_additional_limit_as_the_primary_qu
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
@@ -537,6 +538,7 @@ async fn openai_admin_projects_confirmed_quota_exhaustion_as_full_without_mutati
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist raw quota");
|
||||
|
||||
@@ -358,6 +358,7 @@ async fn passive_rate_limit_headers_replace_stale_secondary_window() {
|
||||
baseline.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist baseline quota");
|
||||
@@ -436,6 +437,7 @@ async fn periodic_quota_synchronization_does_not_scan_stale_ready_accounts() {
|
||||
)),
|
||||
// 正常账号即使缓存已旧,也只等待真实请求被动同步,不能被定时任务主动拉取。
|
||||
observed_at: Some(SystemTime::UNIX_EPOCH),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist stale quota");
|
||||
@@ -481,6 +483,7 @@ async fn periodic_quota_synchronization_preserves_availability_when_usage_peaks(
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist exhausted quota");
|
||||
@@ -718,6 +721,7 @@ async fn periodic_quota_synchronization_does_not_recover_from_percent_drop_befor
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed post-failure quota snapshot");
|
||||
|
||||
@@ -155,6 +155,7 @@ async fn expired_old_reset_with_fresh_allowed_snapshot_recovers_ready() {
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed old exhausted snapshot");
|
||||
|
||||
@@ -196,6 +196,7 @@ async fn persisted_provider_quota_projects_dynamic_windows_without_network_io()
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
@@ -243,6 +244,7 @@ async fn roll_expired_windows_is_o1_for_extremely_old_reset() {
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
@@ -284,6 +286,7 @@ async fn scheduling_signal_survives_limit_reached_without_percent_or_reset() {
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
@@ -335,6 +338,7 @@ async fn persisted_codex_additional_limit_replaces_the_top_level_rate_limit() {
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
@@ -404,6 +408,7 @@ async fn code_review_limit_projects_as_independent_window_with_limit_name() {
|
||||
raw.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
|
||||
@@ -1293,6 +1293,7 @@ async fn usage_limit_failure_returns_promptly_and_refreshes_the_authoritative_qu
|
||||
.clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed stale passive quota");
|
||||
|
||||
@@ -451,6 +451,9 @@ impl GrokCredentialQuotaService {
|
||||
loaded.account.revision(),
|
||||
document,
|
||||
observed_at.into(),
|
||||
presentation
|
||||
.used_percent()
|
||||
.is_some_and(|used| used.is_finite() && used >= 100.0),
|
||||
)
|
||||
.await
|
||||
.map_err(map_quota_repository_error)?;
|
||||
|
||||
@@ -519,6 +519,7 @@ impl GrokCredentialRepository {
|
||||
expected_revision: CredentialRevision,
|
||||
document: serde_json::Map<String, Value>,
|
||||
observed_at: SystemTime,
|
||||
limit_reached: bool,
|
||||
) -> Result<(), GrokCredentialRepositoryError> {
|
||||
let outcome = self
|
||||
.store
|
||||
@@ -527,6 +528,7 @@ impl GrokCredentialRepository {
|
||||
expected_revision,
|
||||
quota: Some(OpaqueProviderData::new(document)),
|
||||
observed_at: Some(observed_at),
|
||||
limit_reached: Some(limit_reached),
|
||||
})
|
||||
.await
|
||||
.map_err(map_store_error)?;
|
||||
|
||||
@@ -919,6 +919,7 @@ async fn quota_read_rejects_corrupt_provider_document() {
|
||||
expected_revision: CredentialRevision::new(1).expect("revision"),
|
||||
quota: Some(OpaqueProviderData::new(document)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("seed corrupt quota");
|
||||
|
||||
@@ -245,6 +245,7 @@ impl SelectorFixture {
|
||||
document.as_object().expect("quota object").clone(),
|
||||
)),
|
||||
observed_at: Some(SystemTime::now()),
|
||||
limit_reached: None,
|
||||
})
|
||||
.await
|
||||
.expect("persist quota");
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
54be07512951a561391b6e611355a1bfc119d82363253fb9bbdb225c9e3e39bb 0001_initial.sql
|
||||
f480ac4cb70f6963eee71c0b8a67777ebb492f85501c90ebe7544bee0b2c0525 0002_remove_account_cooldown.sql
|
||||
5cf5b5387c66b4d437d79c002cec46cb85196789060fa87c39e97a382487cac9 0004_fix_quota_limit_reached_null.sql
|
||||
a3f236d558e7598f69277520458c801ee43130f7704ca25455df468cf8899ff2 0003_materialize_quota_limit_reached.sql
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 物化快照级限流事实:Dashboard 复用 Provider 解析结果,不再 SQL 解析 raw quota JSON。
|
||||
--
|
||||
-- `quota_limit_reached` 由 Provider 在写 quota 时计算(快照级:顶层或任一窗口触顶,
|
||||
-- 已过 reset 的窗口不计数),Admin/Dashboard 直接读该列。
|
||||
|
||||
alter table provider_accounts
|
||||
add column quota_limit_reached boolean not null default false;
|
||||
|
||||
-- 存量回填:从现有 raw JSON 推导(仅 primary_window 的 limit_reached + reset 未过期)。
|
||||
update provider_accounts
|
||||
set quota_limit_reached = coalesce(
|
||||
provider_quota_json is not null
|
||||
and provider_quota_json->'rate_limit'->>'limit_reached' = 'true'
|
||||
and (
|
||||
provider_quota_json->'rate_limit'->'primary_window'->>'reset_at' is null
|
||||
or (provider_quota_json->'rate_limit'->'primary_window'->>'reset_at')::bigint > extract(epoch from now())::bigint
|
||||
),
|
||||
false
|
||||
)
|
||||
where provider_quota_json is not null;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- 修正 0003 存量回填:raw JSON 缺 rate_limit 键时表达式为 NULL,
|
||||
-- 赋 NULL 违反 not-null 约束。0004 把仍为 NULL 的行兜底为 false
|
||||
-- (0003 已应用的环境,add column 用 default false 填充,通常无 NULL;
|
||||
-- 本迁移保证任何环境下该列都非 NULL)。
|
||||
|
||||
update provider_accounts
|
||||
set quota_limit_reached = coalesce(quota_limit_reached, false)
|
||||
where quota_limit_reached is null;
|
||||
Reference in New Issue
Block a user