diff --git a/.github/workflows/_quality.yml b/.github/workflows/_quality.yml index 38dd91bad..907a52c01 100644 --- a/.github/workflows/_quality.yml +++ b/.github/workflows/_quality.yml @@ -27,11 +27,12 @@ jobs: RUSTFLAGS: -D warnings services: postgres: - image: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 + image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818 env: POSTGRES_DB: codex_proxy POSTGRES_USER: codex_proxy POSTGRES_PASSWORD: codex_proxy + POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 ports: - 5432:5432 options: >- diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 744057316..9e5982f83 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -2315,7 +2315,6 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid", "webpki-roots", ] @@ -2382,7 +2381,6 @@ dependencies = [ "sqlx-core", "thiserror", "tracing", - "uuid", ] [[package]] @@ -2418,7 +2416,6 @@ dependencies = [ "stringprep", "thiserror", "tracing", - "uuid", "whoami", ] @@ -2445,7 +2442,6 @@ dependencies = [ "thiserror", "tracing", "url", - "uuid", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index def90a7d6..55d5e0cb9 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -35,7 +35,7 @@ semver = "1.0.27" serde = { version = "1.0.228", features = ["derive"] } serde_json = { version = "1.0.150", features = ["preserve_order"] } sha2 = "0.10.9" -sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite-bundled", "chrono", "uuid", "json"] } +sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "sqlite-bundled", "chrono", "json"] } subtle = "2.6.1" tar = "0.4.44" thiserror = "2.0.18" diff --git a/backend/src/api/admin/router.rs b/backend/src/api/admin/router.rs index 9aefced22..397b2e43e 100644 --- a/backend/src/api/admin/router.rs +++ b/backend/src/api/admin/router.rs @@ -1,6 +1,9 @@ //! 管理端路由。 use axum::{ + http::{header, HeaderValue}, + middleware, + response::Response, routing::{get, post}, Router, }; @@ -104,4 +107,12 @@ pub fn router() -> Router { .route("/api/admin/keys", get(api_keys).post(create_api_key)) .route("/api/admin/keys/delete", post(batch_delete_api_keys)) .route("/api/admin/keys/update", post(update_api_key)) + .layer(middleware::map_response(no_store)) +} + +async fn no_store(mut response: Response) -> Response { + response + .headers_mut() + .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store")); + response } diff --git a/backend/src/api/client/auth.rs b/backend/src/api/client/auth.rs index d3edcd2dd..7ccaccb88 100644 --- a/backend/src/api/client/auth.rs +++ b/backend/src/api/client/auth.rs @@ -97,7 +97,7 @@ fn log_client_api_key_auth_failure(error: &ClientApiKeyAuthError) { ); } _ => { - tracing::warn!( + tracing::info!( auth_failure = error.reason(), "client api key authorization failed" ); diff --git a/backend/src/api/client/router.rs b/backend/src/api/client/router.rs index 78e7e8704..58d742507 100644 --- a/backend/src/api/client/router.rs +++ b/backend/src/api/client/router.rs @@ -13,6 +13,8 @@ use super::{ responses::{compact_responses, responses, review_responses}, }; +pub const MAX_CLIENT_REQUEST_BODY_BYTES: usize = 16 * 1024 * 1024; + /// 构造 OpenAI 兼容 API 路由。 pub fn router() -> Router { Router::new() @@ -23,5 +25,5 @@ pub fn router() -> Router { .route("/v1/models/catalog", get(model_catalog)) .route("/v1/models/{model_id}", get(model_detail)) .route("/v1/models/{model_id}/info", get(model_info)) - .layer(DefaultBodyLimit::disable()) + .layer(DefaultBodyLimit::max(MAX_CLIENT_REQUEST_BODY_BYTES)) } diff --git a/backend/src/bootstrap/import_sqlite/core.rs b/backend/src/bootstrap/import_sqlite/core.rs index 4c49a5aee..ac3e854ef 100644 --- a/backend/src/bootstrap/import_sqlite/core.rs +++ b/backend/src/bootstrap/import_sqlite/core.rs @@ -18,7 +18,6 @@ pub(super) async fn import_core_tables( import_runtime_settings(source, target, report).await?; import_accounts(source, target, report).await?; import_account_usage(source, target, report).await?; - import_account_model_usage(source, target, report).await?; import_account_cookies(source, target, report).await?; import_fingerprints(source, target, report).await?; import_fingerprint_history(source, target, report).await @@ -302,39 +301,6 @@ async fn import_account_usage( Ok(()) } -async fn import_account_model_usage( - source: &SqlitePool, - target: &mut Transaction<'_, Postgres>, - report: &mut ImportSqliteReport, -) -> Result<(), ImportSqliteError> { - let mut rows = - sqlx::query("select * from account_model_usage order by account_id, model").fetch(source); - while let Some(row) = rows.try_next().await? { - sqlx::query( - "insert into account_model_usage - (account_id, model, request_count, error_count, input_tokens, output_tokens, - cached_tokens, last_used_at) - values ($1, $2, $3, $4, $5, $6, $7, $8)", - ) - .bind(row.try_get::("account_id")?) - .bind(row.try_get::("model")?) - .bind(row.try_get::("request_count")?) - .bind(row.try_get::("error_count")?) - .bind(row.try_get::("input_tokens")?) - .bind(row.try_get::("output_tokens")?) - .bind(row.try_get::("cached_tokens")?) - .bind(parse_optional_timestamp( - "account_model_usage", - "last_used_at", - row.try_get("last_used_at")?, - )?) - .execute(&mut **target) - .await?; - report.add_imported("account_model_usage"); - } - Ok(()) -} - async fn import_account_cookies( source: &SqlitePool, target: &mut Transaction<'_, Postgres>, @@ -474,18 +440,21 @@ pub(super) async fn count_discarded_runtime_tables( source: &SqlitePool, report: &mut ImportSqliteReport, ) -> Result<(), ImportSqliteError> { - let (sessions, affinities, leases, snapshots): (i64, i64, i64, i64) = sqlx::query_as( - "select + let (sessions, affinities, leases, snapshots, model_usage): (i64, i64, i64, i64, i64) = + sqlx::query_as( + "select (select count(*) from admin_sessions), (select count(*) from session_affinities), (select count(*) from account_refresh_leases), - (select count(*) from model_plan_snapshots)", - ) - .fetch_one(source) - .await?; + (select count(*) from model_plan_snapshots), + (select count(*) from account_model_usage)", + ) + .fetch_one(source) + .await?; report.add_discarded("admin_sessions", sessions.max(0) as u64); report.add_discarded("session_affinities", affinities.max(0) as u64); report.add_discarded("account_refresh_leases", leases.max(0) as u64); report.add_discarded("model_plan_snapshots", snapshots.max(0) as u64); + report.add_discarded("account_model_usage", model_usage.max(0) as u64); Ok(()) } diff --git a/backend/src/bootstrap/import_sqlite/mod.rs b/backend/src/bootstrap/import_sqlite/mod.rs index 0d513ae7a..9a1009dc6 100644 --- a/backend/src/bootstrap/import_sqlite/mod.rs +++ b/backend/src/bootstrap/import_sqlite/mod.rs @@ -156,7 +156,6 @@ async fn reject_nonempty_target( + (select count(*) from runtime_settings) + (select count(*) from accounts) + (select count(*) from account_usage) - + (select count(*) from account_model_usage) + (select count(*) from usage_records) + (select count(*) from ops_error_logs) + (select count(*) from request_time_buckets) diff --git a/backend/src/bootstrap/tasks/coordinator.rs b/backend/src/bootstrap/tasks/coordinator.rs index 566b2b18f..33572d4e5 100644 --- a/backend/src/bootstrap/tasks/coordinator.rs +++ b/backend/src/bootstrap/tasks/coordinator.rs @@ -46,7 +46,6 @@ impl TaskCoordinator { stores.usage_records.clone(), stores.ops_errors.clone(), stores.request_buckets.clone(), - stores.account_usage.clone(), stores.fingerprints.clone(), ) .start(), diff --git a/backend/src/bootstrap/tasks/retention_trim.rs b/backend/src/bootstrap/tasks/retention_trim.rs index 44064e096..b21df93f0 100644 --- a/backend/src/bootstrap/tasks/retention_trim.rs +++ b/backend/src/bootstrap/tasks/retention_trim.rs @@ -1,14 +1,12 @@ //! PostgreSQL 增长表保留期清理任务。 -use std::num::NonZeroU32; - use chrono::Utc; use tracing::{info, warn}; use crate::{ telemetry::{ - account_usage::store::PgAccountUsageStore, buckets::store::PgRequestBucketStore, - ops::store::PgOpsErrorLogStore, usage::store::PgUsageRecordStore, + buckets::store::PgRequestBucketStore, ops::store::PgOpsErrorLogStore, + usage::store::PgUsageRecordStore, }, upstream::openai::fingerprint::PgFingerprintStore, }; @@ -19,14 +17,12 @@ use super::{ }; const RETENTION_TRIM_INTERVAL_SECS: u64 = 60 * 60; -const ACCOUNT_MODEL_USAGE_LIMIT: NonZeroU32 = NonZeroU32::new(100).unwrap(); -const FINGERPRINT_HISTORY_LIMIT: NonZeroU32 = NonZeroU32::new(100).unwrap(); +const FINGERPRINT_HISTORY_LIMIT: std::num::NonZeroU32 = std::num::NonZeroU32::new(100).unwrap(); pub struct RetentionTrimTask { usage_records: PgUsageRecordStore, ops_errors: PgOpsErrorLogStore, buckets: PgRequestBucketStore, - account_usage: PgAccountUsageStore, fingerprints: PgFingerprintStore, } @@ -35,14 +31,12 @@ impl RetentionTrimTask { usage_records: PgUsageRecordStore, ops_errors: PgOpsErrorLogStore, buckets: PgRequestBucketStore, - account_usage: PgAccountUsageStore, fingerprints: PgFingerprintStore, ) -> Self { Self { usage_records, ops_errors, buckets, - account_usage, fingerprints, } } @@ -57,49 +51,41 @@ impl RetentionTrimTask { ), ) } + + /// 立即执行一轮全部 PostgreSQL 保留期清理。 + pub async fn run_once(&self) { + let now = Utc::now(); + match self.usage_records.trim_to_retention(now).await { + Ok(deleted) if deleted > 0 => info!(deleted, "已清理过期成功使用事实"), + Ok(_) => {} + Err(error) => warn!(error = %error, "清理成功使用事实失败"), + } + match self.ops_errors.trim_to_retention(now).await { + Ok(deleted) if deleted > 0 => info!(deleted, "已清理过期运维错误事实"), + Ok(_) => {} + Err(error) => warn!(error = %error, "清理运维错误事实失败"), + } + match self.buckets.trim_to_retention(now).await { + Ok(deleted) if deleted > 0 => info!(deleted, "已清理过期请求时间桶"), + Ok(_) => {} + Err(error) => warn!(error = %error, "清理请求时间桶失败"), + } + match self + .fingerprints + .trim_update_history_to_limit(FINGERPRINT_HISTORY_LIMIT) + .await + { + Ok(deleted) if deleted > 0 => { + info!(deleted, "已清理超限指纹更新历史") + } + Ok(_) => {} + Err(error) => warn!(error = %error, "清理超限指纹更新历史失败"), + } + } } impl PeriodicTaskRunner for RetentionTrimTask { fn tick(&mut self) -> TaskFuture<'_, ()> { - Box::pin(async move { - let now = Utc::now(); - match self.usage_records.trim_to_retention(now).await { - Ok(deleted) if deleted > 0 => info!(deleted, "已清理过期成功使用事实"), - Ok(_) => {} - Err(error) => warn!(error = %error, "清理成功使用事实失败"), - } - match self.ops_errors.trim_to_retention(now).await { - Ok(deleted) if deleted > 0 => info!(deleted, "已清理过期运维错误事实"), - Ok(_) => {} - Err(error) => warn!(error = %error, "清理运维错误事实失败"), - } - match self.buckets.trim_to_retention(now).await { - Ok(deleted) if deleted > 0 => info!(deleted, "已清理过期请求时间桶"), - Ok(_) => {} - Err(error) => warn!(error = %error, "清理请求时间桶失败"), - } - match self - .account_usage - .trim_model_usage_to_limit(ACCOUNT_MODEL_USAGE_LIMIT) - .await - { - Ok(deleted) if deleted > 0 => { - info!(deleted, "已清理超限账号模型用量统计") - } - Ok(_) => {} - Err(error) => warn!(error = %error, "清理超限账号模型用量统计失败"), - } - match self - .fingerprints - .trim_update_history_to_limit(FINGERPRINT_HISTORY_LIMIT) - .await - { - Ok(deleted) if deleted > 0 => { - info!(deleted, "已清理超限指纹更新历史") - } - Ok(_) => {} - Err(error) => warn!(error = %error, "清理超限指纹更新历史失败"), - } - }) + Box::pin(self.run_once()) } } diff --git a/backend/src/dispatch/affinity/service.rs b/backend/src/dispatch/affinity/service.rs index bd35c56c3..abaead058 100644 --- a/backend/src/dispatch/affinity/service.rs +++ b/backend/src/dispatch/affinity/service.rs @@ -9,6 +9,9 @@ use super::{ }; const DEFAULT_SESSION_AFFINITY_TTL_SECS: i64 = 4 * 60 * 60; +pub const MAX_REPLAY_SNAPSHOT_BYTES: u64 = 2 * 1024 * 1024; +pub const MAX_REPLAY_SESSION_BYTES: u64 = 16 * 1024 * 1024; +pub const MAX_REPLAY_DEPTH: u16 = 128; /// 运行时会话亲和性服务。 #[derive(Clone)] @@ -68,6 +71,36 @@ impl SessionAffinityService { self.entry(response_id, now).await } + pub async fn replay_input( + &self, + response_id: &str, + entry: &SessionAffinityEntry, + now: DateTime, + ) -> Option> { + match self + .store + .replay_input( + response_id, + entry, + now, + self.ttl, + MAX_REPLAY_DEPTH, + MAX_REPLAY_SESSION_BYTES, + ) + .await + { + Ok(input) => input, + Err(error) => { + tracing::warn!( + response_id, + error = %error, + "failed to reconstruct Redis response replay history" + ); + None + } + } + } + pub async fn lookup_instructions_hash( &self, response_id: &str, diff --git a/backend/src/dispatch/affinity/store.rs b/backend/src/dispatch/affinity/store.rs index 7ee6aad26..80a35aea3 100644 --- a/backend/src/dispatch/affinity/store.rs +++ b/backend/src/dispatch/affinity/store.rs @@ -1,7 +1,10 @@ //! Redis 会话亲和存储。 +use std::collections::HashMap; + use chrono::{DateTime, Duration, Utc}; use redis::AsyncCommands; +use serde_json::Value; use thiserror::Error; use crate::infra::redis::RedisConnection; @@ -69,18 +72,24 @@ impl RedisSessionAffinityStore { .arg(&conversation_key) .arg(ttl_seconds) .ignore() - .cmd("SADD") + .cmd("ZADD") .arg(&account_key) + .arg(entry.created_at.timestamp_millis()) .arg(response_id) .ignore() .cmd("EXPIRE") - .arg(account_key) + .arg(&account_key) .arg(ttl_seconds) .ignore() .cmd("ZREMRANGEBYSCORE") .arg(conversation_key) .arg("-inf") .arg(cutoff_ms) + .ignore() + .cmd("ZREMRANGEBYSCORE") + .arg(&account_key) + .arg("-inf") + .arg(cutoff_ms) .ignore(); let _: () = transaction.query_async(&mut connection).await?; Ok(()) @@ -142,6 +151,107 @@ impl RedisSessionAffinityStore { Ok(None) } + pub async fn replay_input( + &self, + response_id: &str, + head: &SessionAffinityEntry, + now: DateTime, + ttl: Duration, + max_depth: u16, + max_total_bytes: u64, + ) -> RedisSessionAffinityStoreResult>> { + let Some(head_snapshot) = head.replay.as_ref() else { + return Ok(None); + }; + if head_snapshot.depth == 0 + || head_snapshot.depth > max_depth + || head_snapshot.total_bytes > max_total_bytes + { + return Ok(None); + } + + let conversation_key = self.conversation_key(&head.conversation_id); + let mut connection = self.redis.manager(); + let response_ids: Vec = redis::cmd("ZREVRANGEBYSCORE") + .arg(&conversation_key) + .arg(head.created_at.timestamp_millis()) + .arg("-inf") + .arg("LIMIT") + .arg(0) + .arg(max_depth) + .query_async(&mut connection) + .await?; + if !response_ids + .iter() + .any(|candidate| candidate == response_id) + { + return Ok(None); + } + + let response_keys = response_ids + .iter() + .map(|candidate| self.response_key(candidate)) + .collect::>(); + let values: Vec> = redis::cmd("MGET") + .arg(&response_keys) + .query_async(&mut connection) + .await?; + let mut entries = HashMap::with_capacity(values.len()); + for (candidate, value) in response_ids.into_iter().zip(values) { + let Some(value) = value else { + continue; + }; + let entry: SessionAffinityEntry = serde_json::from_str(&value)?; + if entry.conversation_id == head.conversation_id + && entry + .created_at + .checked_add_signed(ttl) + .is_some_and(|expires_at| expires_at > now) + { + entries.insert(candidate, entry); + } + } + + let mut current_id = response_id.to_string(); + let mut expected_depth = head_snapshot.depth; + let mut expected_total_bytes = head_snapshot.total_bytes; + let mut nodes = Vec::with_capacity(usize::from(expected_depth)); + loop { + let Some(entry) = entries.get(¤t_id) else { + return Ok(None); + }; + let Some(snapshot) = entry.replay.as_ref() else { + return Ok(None); + }; + if snapshot.depth != expected_depth || snapshot.total_bytes != expected_total_bytes { + return Ok(None); + } + let node_bytes = replay_node_bytes(snapshot)?; + if node_bytes > expected_total_bytes { + return Ok(None); + } + nodes.push((snapshot.turn_input.clone(), snapshot.turn_output.clone())); + + match snapshot.parent_response_id.as_deref() { + None if expected_depth == 1 && expected_total_bytes == node_bytes => break, + Some(parent_response_id) if expected_depth > 1 => { + expected_depth -= 1; + expected_total_bytes -= node_bytes; + current_id = parent_response_id.to_string(); + } + None | Some(_) => return Ok(None), + } + } + + nodes.reverse(); + let mut full_input = Vec::new(); + for (mut input, mut output) in nodes { + full_input.append(&mut input); + full_input.append(&mut output); + } + Ok(Some(full_input)) + } + pub async fn forget(&self, response_id: &str) -> RedisSessionAffinityStoreResult { let response_key = self.response_key(response_id); let mut connection = self.redis.manager(); @@ -160,7 +270,7 @@ impl RedisSessionAffinityStore { .arg(self.conversation_key(&entry.conversation_id)) .arg(response_id) .ignore() - .cmd("SREM") + .cmd("ZREM") .arg(self.account_key(&entry.account_id)) .arg(response_id) .ignore(); @@ -171,13 +281,59 @@ impl RedisSessionAffinityStore { pub async fn forget_account(&self, account_id: &str) -> RedisSessionAffinityStoreResult { let account_key = self.account_key(account_id); let mut connection = self.redis.manager(); - let response_ids: Vec = connection.smembers(&account_key).await?; - let mut forgotten = 0u64; - for response_id in response_ids { - forgotten += u64::from(self.forget(&response_id).await?); + let response_ids: Vec = connection.zrange(&account_key, 0, -1).await?; + if response_ids.is_empty() { + let _: usize = connection.del(account_key).await?; + return Ok(0); } - let _: usize = connection.del(account_key).await?; - Ok(forgotten) + + let response_keys = response_ids + .iter() + .map(|response_id| self.response_key(response_id)) + .collect::>(); + let values: Vec> = redis::cmd("MGET") + .arg(&response_keys) + .query_async(&mut connection) + .await?; + let mut conversation_members = Vec::with_capacity(response_ids.len()); + for (response_id, value) in response_ids.iter().zip(values) { + let Some(value) = value else { + continue; + }; + match serde_json::from_str::(&value) { + Ok(entry) if entry.account_id == account_id => { + conversation_members.push((response_id, entry.conversation_id)); + } + Ok(_) => { + tracing::warn!( + response_id, + account_id, + "account affinity index points to another account" + ); + } + Err(error) => { + tracing::warn!( + response_id, + account_id, + error = %error, + "invalid account affinity entry removed during account cleanup" + ); + } + } + } + + let mut transaction = redis::pipe(); + transaction.atomic().cmd("DEL").arg(&response_keys).ignore(); + for (response_id, conversation_id) in &conversation_members { + transaction + .cmd("ZREM") + .arg(self.conversation_key(conversation_id)) + .arg(response_id) + .ignore(); + } + transaction.cmd("DEL").arg(account_key).ignore(); + let _: () = transaction.query_async(&mut connection).await?; + Ok(response_ids.len() as u64) } fn response_key(&self, response_id: &str) -> String { @@ -193,3 +349,10 @@ impl RedisSessionAffinityStore { self.redis.key(&format!("affinity:v2:account:{account_id}")) } } + +fn replay_node_bytes( + snapshot: &super::types::ResponseReplaySnapshot, +) -> Result { + serde_json::to_vec(&(&snapshot.turn_input, &snapshot.turn_output)) + .map(|value| value.len() as u64) +} diff --git a/backend/src/dispatch/affinity/types.rs b/backend/src/dispatch/affinity/types.rs index 8c479f984..c071f2aca 100644 --- a/backend/src/dispatch/affinity/types.rs +++ b/backend/src/dispatch/affinity/types.rs @@ -22,9 +22,18 @@ pub struct SessionAffinityEntry { pub created_at: DateTime, } -/// 截止某个 completed response 的完整、无凭据业务输入快照。 +/// 单轮 completed response 的有界、无凭据重放增量。 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResponseReplaySnapshot { - pub full_input: Vec, + /// 上一轮由本代理记录的 completed response。 + pub parent_response_id: Option, + /// 本轮客户端新增的输入,已剥离账号绑定字段。 + pub turn_input: Vec, + /// 本轮上游输出,已剥离账号绑定字段和上游对象 ID。 + pub turn_output: Vec, + /// 从会话起点到本节点的增量节点数。 + pub depth: u16, + /// 从会话起点到本节点的 JSON 编码累计字节数。 + pub total_bytes: u64, } diff --git a/backend/src/dispatch/recording.rs b/backend/src/dispatch/recording.rs index 13c97e064..1a013c8c7 100644 --- a/backend/src/dispatch/recording.rs +++ b/backend/src/dispatch/recording.rs @@ -107,7 +107,7 @@ pub(super) async fn record_response_dispatch_error_event( insert_response_status_metadata_object( object, i64::from(record.error.http_status_code()), - dispatch_error_client_status_code(record.error, record.stream), + dispatch_error_client_status_code(record.error), dispatch_error_upstream_status_code(record.error), ); } @@ -152,7 +152,7 @@ pub(super) async fn record_response_upstream_error_event( insert_response_status_metadata_object( object, event_status_code, - upstream_failure_client_status_code(event_status_code, record.stream), + upstream_failure_client_status_code(event_status_code), Some(event_status_code), ); insert_response_request_summary_object(object, record.request, record.transport); @@ -453,21 +453,14 @@ fn dispatch_error_upstream_status_code(error: &ResponseDispatchError) -> Option< } } -fn dispatch_error_client_status_code(error: &ResponseDispatchError, stream: bool) -> i64 { - if stream { - return 200; - } +fn dispatch_error_client_status_code(error: &ResponseDispatchError) -> i64 { i64::from(error.client_http_status_code()) } -fn upstream_failure_client_status_code(upstream_status: i64, stream: bool) -> i64 { - if stream { - 200 - } else { - i64::from(crate::dispatch::errors::client_upstream_http_status_code( - u16::try_from(upstream_status).unwrap_or(502), - )) - } +fn upstream_failure_client_status_code(upstream_status: i64) -> i64 { + i64::from(crate::dispatch::errors::client_upstream_http_status_code( + u16::try_from(upstream_status).unwrap_or(502), + )) } pub(super) async fn record_live_response_stream_event( diff --git a/backend/src/dispatch/recovery/history.rs b/backend/src/dispatch/recovery/history.rs index c2abfd7e5..902bcd3ff 100644 --- a/backend/src/dispatch/recovery/history.rs +++ b/backend/src/dispatch/recovery/history.rs @@ -5,7 +5,9 @@ use serde_json::Value; use crate::{ dispatch::affinity::{ - types::ResponseReplaySnapshot, SessionAffinityEntry, SessionAffinityService, + service::{MAX_REPLAY_DEPTH, MAX_REPLAY_SESSION_BYTES, MAX_REPLAY_SNAPSHOT_BYTES}, + types::ResponseReplaySnapshot, + SessionAffinityEntry, SessionAffinityService, }, upstream::openai::protocol::responses::{CodexResponsesRequest, StreamCommitPolicy}, }; @@ -22,6 +24,7 @@ pub(in crate::dispatch) enum HistorySource { pub(in crate::dispatch) struct HistoryRecoveryPlan { source: HistorySource, managed_entry: Option, + replay_input: Option>, external_attempted_account_id: Option, replay_owner_account: bool, } @@ -35,6 +38,7 @@ impl HistoryRecoveryPlan { return Self { source: HistorySource::None, managed_entry: None, + replay_input: None, external_attempted_account_id: None, replay_owner_account: false, }; @@ -42,6 +46,14 @@ impl HistoryRecoveryPlan { let managed_entry = session_affinity .lookup(previous_response_id, Utc::now()) .await; + let replay_input = match managed_entry.as_ref() { + Some(entry) => { + session_affinity + .replay_input(previous_response_id, entry, Utc::now()) + .await + } + None => None, + }; Self { source: if managed_entry.is_some() { HistorySource::Managed @@ -49,6 +61,7 @@ impl HistoryRecoveryPlan { HistorySource::ExternalUnknown }, managed_entry, + replay_input, external_attempted_account_id: None, replay_owner_account: false, } @@ -99,8 +112,8 @@ impl HistoryRecoveryPlan { } Some(request) } else { - entry.replay.as_ref().map(|snapshot| { - let mut request = full_replay_request(original, snapshot); + self.replay_input.as_ref().map(|replay_input| { + let mut request = full_replay_request(original, replay_input); request.local_conversation_id = Some(entry.conversation_id.clone()); request }) @@ -114,7 +127,10 @@ impl HistoryRecoveryPlan { let Some(entry) = self.managed_entry.as_ref() else { return false; }; - if entry.account_id != account_id || entry.replay.is_none() || self.replay_owner_account { + if entry.account_id != account_id + || self.replay_input.is_none() + || self.replay_owner_account + { return false; } self.replay_owner_account = true; @@ -126,7 +142,7 @@ impl HistoryRecoveryPlan { && self .managed_entry .as_ref() - .is_none_or(|entry| entry.replay.is_some()) + .is_none_or(|_| self.replay_input.is_some()) } pub(in crate::dispatch) fn is_external_unknown(&self) -> bool { @@ -138,22 +154,49 @@ impl HistoryRecoveryPlan { original: &CodexResponsesRequest, output: &[Value], ) -> Option { - let mut full_input = match self.source { - HistorySource::None => original.input().to_vec(), - HistorySource::Managed => self - .managed_entry - .as_ref()? - .replay - .as_ref()? - .full_input - .clone(), + let (parent_response_id, parent_depth, parent_bytes) = match self.source { + HistorySource::None => (None, 0, 0), + HistorySource::Managed => { + self.replay_input.as_ref()?; + let replay = self.managed_entry.as_ref()?.replay.as_ref()?; + ( + original.previous_response_id().map(ToString::to_string), + replay.depth, + replay.total_bytes, + ) + } HistorySource::ExternalUnknown => return None, }; - if matches!(self.source, HistorySource::Managed) { - full_input.extend_from_slice(original.input()); + let depth = parent_depth.checked_add(1)?; + if depth > MAX_REPLAY_DEPTH { + tracing::warn!(depth, "response replay depth limit reached"); + return None; } - full_input.extend_from_slice(output); - Some(ResponseReplaySnapshot { full_input }) + + let mut turn_input = original.input().to_vec(); + let mut turn_output = output.to_vec(); + sanitize_replay_items(&mut turn_input); + sanitize_replay_items(&mut turn_output); + let Some(node_bytes) = limited_replay_json_bytes(&turn_input, &turn_output) else { + tracing::warn!("response replay snapshot byte limit reached"); + return None; + }; + let total_bytes = parent_bytes.checked_add(node_bytes)?; + if total_bytes > MAX_REPLAY_SESSION_BYTES { + tracing::warn!( + node_bytes, + total_bytes, + "response replay session byte limit reached" + ); + return None; + } + Some(ResponseReplaySnapshot { + parent_response_id, + turn_input, + turn_output, + depth, + total_bytes, + }) } pub(in crate::dispatch) fn conversation_id<'a>( @@ -169,12 +212,12 @@ impl HistoryRecoveryPlan { fn full_replay_request( original: &CodexResponsesRequest, - snapshot: &ResponseReplaySnapshot, + replay_input: &[Value], ) -> CodexResponsesRequest { let mut request = original.clone(); - let mut input = snapshot.full_input.clone(); + let mut input = replay_input.to_vec(); input.extend_from_slice(original.input()); - strip_account_bound_encrypted_content(&mut input); + sanitize_replay_items(&mut input); request.set_input(input); request.set_previous_response_id(None); request.turn_state = None; @@ -182,14 +225,15 @@ fn full_replay_request( request } -fn strip_account_bound_encrypted_content(values: &mut [Value]) { +fn sanitize_replay_items(values: &mut [Value]) { for value in values { match value { - Value::Array(values) => strip_account_bound_encrypted_content(values), + Value::Array(values) => sanitize_replay_items(values), Value::Object(object) => { + object.remove("id"); object.remove("encrypted_content"); for value in object.values_mut() { - strip_account_bound_encrypted_content(std::slice::from_mut(value)); + strip_encrypted_content(std::slice::from_mut(value)); } } Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} @@ -197,6 +241,53 @@ fn strip_account_bound_encrypted_content(values: &mut [Value]) { } } +fn strip_encrypted_content(values: &mut [Value]) { + for value in values { + match value { + Value::Array(values) => strip_encrypted_content(values), + Value::Object(object) => { + object.remove("encrypted_content"); + for value in object.values_mut() { + strip_encrypted_content(std::slice::from_mut(value)); + } + } + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => {} + } + } +} + +fn limited_replay_json_bytes(input: &[Value], output: &[Value]) -> Option { + let mut writer = ReplaySizeWriter::default(); + serde_json::to_writer(&mut writer, &(input, output)).ok()?; + Some(writer.written) +} + +#[derive(Default)] +struct ReplaySizeWriter { + written: u64, +} + +impl std::io::Write for ReplaySizeWriter { + fn write(&mut self, buffer: &[u8]) -> std::io::Result { + let next = self + .written + .checked_add(buffer.len() as u64) + .filter(|next| *next <= MAX_REPLAY_SNAPSHOT_BYTES) + .ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::FileTooLarge, + "response replay snapshot byte limit reached", + ) + })?; + self.written = next; + Ok(buffer.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + fn non_empty(value: Option<&str>) -> Option<&str> { value.map(str::trim).filter(|value| !value.is_empty()) } diff --git a/backend/src/dispatch/service.rs b/backend/src/dispatch/service.rs index fd13f155f..b6f867418 100644 --- a/backend/src/dispatch/service.rs +++ b/backend/src/dispatch/service.rs @@ -193,12 +193,17 @@ impl ResponseDispatchService { ResponseDispatchAttempt, ) = loop { let acquired = if let Some(account_id) = next_required_account_id.take() { - self.account_pool + match self + .account_pool .acquire_with( &AccountAcquireRequest::new(request.model(), Utc::now()) .with_required_account_id(account_id), ) .await + { + Some(acquired) => Some(acquired), + None => candidates.acquire_next(&self.account_pool).await, + } } else { candidates.acquire_next(&self.account_pool).await }; @@ -328,7 +333,6 @@ impl ResponseDispatchService { self.account_pool .record_empty_response_attempt( &release_account_id, - request.model(), image_generation_requested, ) .await; @@ -481,6 +485,10 @@ impl ResponseDispatchService { } Err(error) => { if is_continuation_busy_error(&error) { + if history.recover_managed_history(&release_account_id) { + next_required_account_id = Some(release_account_id); + continue; + } return Err(ResponseDispatchError::ContinuationBusy); } let history_unavailable = is_history_recovery_upstream_error(&error); @@ -534,12 +542,7 @@ impl ResponseDispatchService { self.cloudflare.reset_account_recovery(&account.id).await; if let Some(usage) = response.usage { self.account_pool - .record_response_usage( - &account.id, - request.model(), - usage, - image_generation_requested, - ) + .record_response_usage(&account.id, usage, image_generation_requested) .await; } if completed { diff --git a/backend/src/dispatch/stream/lifecycle.rs b/backend/src/dispatch/stream/lifecycle.rs index 61b9fa5e7..0cff6efe5 100644 --- a/backend/src/dispatch/stream/lifecycle.rs +++ b/backend/src/dispatch/stream/lifecycle.rs @@ -126,12 +126,17 @@ impl ResponseDispatchService { } loop { let acquired = if let Some(account_id) = next_required_account_id.take() { - self.account_pool + match self + .account_pool .acquire_with( &AccountAcquireRequest::new(request.model(), Utc::now()) .with_required_account_id(account_id), ) .await + { + Some(acquired) => Some(acquired), + None => candidates.acquire_next(&self.account_pool).await, + } } else { candidates.acquire_next(&self.account_pool).await }; @@ -374,6 +379,10 @@ impl ResponseDispatchService { acquired.complete().await; if let ResponseDispatchError::Upstream(upstream_error) = &error { if is_continuation_busy_error(upstream_error) { + if history.recover_managed_history(&release_account_id) { + next_required_account_id = Some(release_account_id); + continue; + } return_stream_dispatch_error!( ResponseDispatchError::ContinuationBusy, account_id: Some(&release_account_id), @@ -552,7 +561,6 @@ impl ResponseDispatchService { account_plan_type: account.plan_type, request_id: request_id.to_string(), route: route.to_string(), - model: request.model().to_string(), display_model: display_model.clone(), requested_model: requested_model.to_string(), client_ip: request.client_ip.clone(), @@ -711,6 +719,10 @@ impl ResponseDispatchService { Err(error) => { acquired.complete().await; if is_continuation_busy_error(&error) { + if history.recover_managed_history(&release_account_id) { + next_required_account_id = Some(release_account_id); + continue; + } return_stream_dispatch_error!( ResponseDispatchError::ContinuationBusy, account_id: Some(&release_account_id), diff --git a/backend/src/dispatch/stream/live.rs b/backend/src/dispatch/stream/live.rs index 87f4d5f7d..c348569e8 100644 --- a/backend/src/dispatch/stream/live.rs +++ b/backend/src/dispatch/stream/live.rs @@ -349,7 +349,6 @@ pub(in crate::dispatch) struct LiveResponseStreamContext { pub(in crate::dispatch) account_plan_type: Option, pub(in crate::dispatch) request_id: String, pub(in crate::dispatch) route: String, - pub(in crate::dispatch) model: String, pub(in crate::dispatch) display_model: String, pub(in crate::dispatch) requested_model: String, pub(in crate::dispatch) client_ip: Option, @@ -418,7 +417,7 @@ pub(in crate::dispatch) async fn finalize_live_response_stream( Ok(Some(usage)) => { context .account_pool - .record_token_usage(&context.account_id, &context.model, &usage) + .record_token_usage(&context.account_id, &usage) .await; Some(usage) } diff --git a/backend/src/dispatch/upstream_call.rs b/backend/src/dispatch/upstream_call.rs index cd6b68aff..c5e670fc1 100644 --- a/backend/src/dispatch/upstream_call.rs +++ b/backend/src/dispatch/upstream_call.rs @@ -388,7 +388,6 @@ async fn retry_upstream_5xx( account_id: &str, endpoint: Option<&str>, retry_message: &'static str, - failure_message: &'static str, mut operation: F, ) -> Result where @@ -414,17 +413,7 @@ where retries += 1; } Ok(response) => return Ok(response), - Err(error) => { - log_upstream_failure( - request_id, - account_id, - endpoint, - retries, - &error, - failure_message, - ); - return Err(error); - } + Err(error) => return Err(error), } } } @@ -438,7 +427,7 @@ fn log_upstream_retry( message: &'static str, ) { if let Some(endpoint) = endpoint { - tracing::warn!( + tracing::debug!( request_id, account_id = %account_id, endpoint, @@ -447,7 +436,7 @@ fn log_upstream_retry( message ); } else { - tracing::warn!( + tracing::debug!( request_id, account_id = %account_id, retry, @@ -457,34 +446,6 @@ fn log_upstream_retry( } } -fn log_upstream_failure( - request_id: &str, - account_id: &str, - endpoint: Option<&str>, - retries: usize, - error: &CodexClientError, - message: &'static str, -) { - if let Some(endpoint) = endpoint { - tracing::warn!( - request_id, - account_id = %account_id, - endpoint, - retries, - error = %error, - message - ); - } else { - tracing::warn!( - request_id, - account_id = %account_id, - retries, - error = %error, - message - ); - } -} - pub(crate) async fn create_response_with_account_retrying_5xx( context: AccountUpstreamContext<'_>, request: &CodexResponsesRequest, @@ -495,7 +456,6 @@ pub(crate) async fn create_response_with_account_retrying_5xx( &context.account.id, None, "upstream response request failed with retryable 5xx", - "upstream response request failed", || create_response_with_account(context, request, started_at), ) .await @@ -510,7 +470,6 @@ pub(crate) async fn create_response_stream_with_account_retrying_5xx( &context.account.id, None, "upstream response stream request failed with retryable 5xx", - "upstream response stream request failed", || create_response_stream_with_account(context, request), ) .await @@ -525,7 +484,6 @@ pub(crate) async fn create_compact_response_with_account_retrying_5xx( &context.account.id, Some("compact"), "upstream compact request failed with retryable 5xx", - "upstream compact request failed", || create_compact_response_with_account(context, request), ) .await @@ -637,7 +595,7 @@ impl ResponseDispatchService { let usage = extract_usage(&response.body); if let Some(usage) = usage { self.account_pool - .record_token_usage(&account.id, request.model(), &usage) + .record_token_usage(&account.id, &usage) .await; } let mut metadata = json!({ diff --git a/backend/src/fleet/manage/import.rs b/backend/src/fleet/manage/import.rs index ee9571e70..c7312811a 100644 --- a/backend/src/fleet/manage/import.rs +++ b/backend/src/fleet/manage/import.rs @@ -167,51 +167,16 @@ impl AccountManageService { added_at: None, }; - match self.store.get(&account_id).await { - Ok(Some(_)) => { - let updated = self - .store - .update_from_import(crate::fleet::store::ImportedAccountUpdate { - account, - quota_json, - quota_fetched_at, - quota_verify_required, - }) - .await - .map_err(|_| AccountManageError::Import)?; - if !updated { - return Err(AccountManageError::NotFound); - } - self.store - .set_next_refresh_at(&account_id, next_refresh_at) - .await - .map_err(|_| AccountManageError::Import)?; - } - Ok(None) => { - self.store - .insert(account) - .await - .map_err(|_| AccountManageError::Import)?; - - self.store - .set_next_refresh_at(&account_id, next_refresh_at) - .await - .map_err(|_| AccountManageError::Import)?; - - if quota_json.is_some() || quota_fetched_at.is_some() || quota_verify_required { - self.store - .apply_imported_quota_state( - &account_id, - quota_json.as_deref(), - quota_fetched_at, - quota_verify_required, - ) - .await - .map_err(|_| AccountManageError::Import)?; - } - } - Err(_) => return Err(AccountManageError::Inspect), - } + self.store + .upsert_from_import(crate::fleet::store::ImportedAccountUpsert { + account, + next_refresh_at, + quota_json, + quota_fetched_at, + quota_verify_required, + }) + .await + .map_err(|_| AccountManageError::Import)?; Ok(ImportedAccountState::Imported(account_id)) } diff --git a/backend/src/fleet/pool/mod.rs b/backend/src/fleet/pool/mod.rs index d15c319ae..3a07f720d 100644 --- a/backend/src/fleet/pool/mod.rs +++ b/backend/src/fleet/pool/mod.rs @@ -24,8 +24,7 @@ use crate::fleet::{ window::should_reset_usage_window, }; use crate::telemetry::account_usage::store::{ - AccountModelUsageDelta, AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, - AccountUsageWindow, + AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, AccountUsageWindow, }; use crate::upstream::openai::protocol::events::{ parse_rate_limit_headers, rate_limit_quota, TokenUsage as CodexTokenUsage, @@ -307,9 +306,9 @@ impl AccountPoolService { .await .release_slot(account_id, slot_id, false) }; - let Some(released) = released else { + if !released { return; - }; + } self.candidate_changed.notify_one(); if !record_request_usage { return; @@ -321,32 +320,6 @@ impl AccountPoolService { "failed to persist account request usage" ); } - let Some(model) = released.model else { - tracing::debug!( - account_id, - "released account without active slot; skipped model request usage persistence" - ); - return; - }; - if let Err(error) = self - .usage_store - .record_model_usage_delta( - account_id, - &model, - AccountModelUsageDelta { - requests: 1, - ..AccountModelUsageDelta::default() - }, - ) - .await - { - tracing::error!( - account_id, - model, - error = %error, - "failed to persist account model request usage" - ); - } } /// 更新账号状态并同步内存池。 @@ -521,16 +494,14 @@ impl AccountPoolService { } /// 记录上游 token 用量。 - pub async fn record_token_usage(&self, account_id: &str, model: &str, usage: &CodexTokenUsage) { - self.record_response_usage(account_id, model, *usage, false) - .await; + pub async fn record_token_usage(&self, account_id: &str, usage: &CodexTokenUsage) { + self.record_response_usage(account_id, *usage, false).await; } /// 记录 Responses 请求用量。 pub async fn record_response_usage( &self, account_id: &str, - model: &str, usage: CodexTokenUsage, image_generation_requested: bool, ) { @@ -559,27 +530,6 @@ impl AccountPoolService { "failed to persist account token usage" ); } - if let Err(error) = self - .usage_store - .record_model_usage_delta( - account_id, - model, - AccountModelUsageDelta { - input_tokens: usage.input_tokens, - output_tokens: usage.output_tokens, - cached_tokens: usage.cached_tokens, - ..AccountModelUsageDelta::default() - }, - ) - .await - { - tracing::error!( - account_id, - model, - error = %error, - "failed to persist account model token usage" - ); - } self.pool.lock().await.record_window_token_usage( account_id, AccountWindowUsageDelta { @@ -598,7 +548,6 @@ impl AccountPoolService { pub async fn record_empty_response_attempt( &self, account_id: &str, - model: &str, image_generation_requested: bool, ) { let usage = AccountUsageDelta { @@ -613,25 +562,6 @@ impl AccountPoolService { "failed to persist empty response usage" ); } - if let Err(error) = self - .usage_store - .record_model_usage_delta( - account_id, - model, - AccountModelUsageDelta { - errors: 1, - ..AccountModelUsageDelta::default() - }, - ) - .await - { - tracing::error!( - account_id, - model, - error = %error, - "failed to persist account model empty response usage" - ); - } if image_generation_requested { self.pool.lock().await.record_window_token_usage( account_id, diff --git a/backend/src/fleet/pool/state.rs b/backend/src/fleet/pool/state.rs index a10b49bd9..df1ae14c1 100644 --- a/backend/src/fleet/pool/state.rs +++ b/backend/src/fleet/pool/state.rs @@ -153,14 +153,6 @@ pub struct AccountPool { struct AccountSlot { id: uuid::Uuid, created_at: DateTime, - model: Option, -} - -/// 账号释放结果。 -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ReleasedAccountUsage { - /// 被释放槽位对应的模型。槽位已被过期清理时为空。 - pub model: Option, } impl Default for AccountPool { @@ -430,7 +422,7 @@ impl AccountPool { ); let selected_id = selected.id.clone(); - let slot_id = self.push_slot(&selected_id, request.now, Some(&request.model)); + let slot_id = self.push_slot(&selected_id, request.now); AccountAcquireWithStatusRefresh { acquired: Some(AcquiredAccount { account: selected, @@ -528,15 +520,12 @@ impl AccountPool { } /// 释放指定账号的一个在途槽位。 - pub fn release(&mut self, acquired: &AcquiredAccount) -> Option { + pub fn release(&mut self, acquired: &AcquiredAccount) -> bool { self.release_slot(&acquired.account.id, acquired.slot_id, true) } /// 释放模型目录刷新选择的账号,不累计客户端请求用量。 - pub fn release_distinct_plan_account( - &mut self, - selected: &DistinctPlanAccount, - ) -> Option { + pub fn release_distinct_plan_account(&mut self, selected: &DistinctPlanAccount) -> bool { self.release_slot(&selected.account.id, selected.slot_id, false) } @@ -545,27 +534,25 @@ impl AccountPool { account_id: &str, slot_id: uuid::Uuid, record_request_usage: bool, - ) -> Option { - self.accounts.get(account_id)?; + ) -> bool { + if !self.accounts.contains_key(account_id) { + return false; + } let mut remove_slots = false; - let slot = self.slots.get_mut(account_id).and_then(|slots| { - let slot = slots - .iter() - .position(|slot| slot.id == slot_id) - .and_then(|position| slots.remove(position)); + if let Some(slots) = self.slots.get_mut(account_id) { + if let Some(position) = slots.iter().position(|slot| slot.id == slot_id) { + slots.remove(position); + } remove_slots = slots.is_empty(); - slot - }); + } if remove_slots { self.slots.remove(account_id); } if record_request_usage { self.mark_request_usage(account_id, Utc::now()); } - Some(ReleasedAccountUsage { - model: slot.and_then(|slot| slot.model), - }) + true } /// 计算账号池容量摘要。 @@ -671,7 +658,7 @@ impl AccountPool { let mut accounts = Vec::with_capacity(selected_accounts.len()); for (plan_type, account) in selected_accounts { - let slot_id = self.push_slot(&account.id, now, None); + let slot_id = self.push_slot(&account.id, now); accounts.push(DistinctPlanAccount { plan_type, account, @@ -696,12 +683,7 @@ impl AccountPool { .map(|slot| slot.created_at) } - fn push_slot( - &mut self, - account_id: &str, - now: DateTime, - model: Option<&str>, - ) -> uuid::Uuid { + fn push_slot(&mut self, account_id: &str, now: DateTime) -> uuid::Uuid { let slot_id = uuid::Uuid::new_v4(); self.slots .entry(account_id.to_string()) @@ -709,7 +691,6 @@ impl AccountPool { .push_back(AccountSlot { id: slot_id, created_at: now, - model: model.map(str::to_string), }); slot_id } diff --git a/backend/src/fleet/store/mod.rs b/backend/src/fleet/store/mod.rs index 74d9994d8..042de4339 100644 --- a/backend/src/fleet/store/mod.rs +++ b/backend/src/fleet/store/mod.rs @@ -94,11 +94,13 @@ pub struct AccountClaimsUpdate { pub status: AccountStatus, } -/// 通过导入数据更新已有账号。 +/// 账号导入的完整原子写入数据。 #[derive(Debug)] -pub struct ImportedAccountUpdate { - /// 新建账号数据。 +pub struct ImportedAccountUpsert { + /// 账号身份和凭据。 pub account: NewAccount, + /// 下一次刷新时间。 + pub next_refresh_at: Option>, /// 缓存配额 JSON。 pub quota_json: Option, /// 配额抓取时间。 diff --git a/backend/src/fleet/store/queries.rs b/backend/src/fleet/store/queries.rs index 08792a212..7f76f84c4 100644 --- a/backend/src/fleet/store/queries.rs +++ b/backend/src/fleet/store/queries.rs @@ -261,56 +261,35 @@ set updated_at = $2 where id = $3"; -pub(super) const UPDATE_IMPORTED_ACCOUNT_WITH_REFRESH_SQL: &str = r" -update accounts -set - email = $1, - chatgpt_account_id = $2, - chatgpt_user_id = $3, - label = $4, - plan_type = $5, - access_token = $6, - refresh_token = $7, - access_token_expires_at = $8, - status = $9, - quota_json = coalesce($10, quota_json), - quota_fetched_at = case when $11 is null then quota_fetched_at else $12 end, +pub(super) const UPSERT_IMPORTED_ACCOUNT_SQL: &str = r" +insert into accounts ( + id, email, chatgpt_account_id, chatgpt_user_id, label, plan_type, + access_token, refresh_token, access_token_expires_at, next_refresh_at, status, + quota_json, quota_fetched_at, quota_limit_reached, quota_verify_required, + quota_cooldown_until, added_at, updated_at +) values ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, false, $14, + null, $15, $16 +) +on conflict(id) do update set + email = excluded.email, + chatgpt_account_id = excluded.chatgpt_account_id, + chatgpt_user_id = excluded.chatgpt_user_id, + label = excluded.label, + plan_type = excluded.plan_type, + access_token = excluded.access_token, + refresh_token = coalesce(excluded.refresh_token, accounts.refresh_token), + access_token_expires_at = excluded.access_token_expires_at, + next_refresh_at = excluded.next_refresh_at, + status = excluded.status, + quota_json = coalesce(excluded.quota_json, accounts.quota_json), + quota_fetched_at = coalesce(excluded.quota_fetched_at, accounts.quota_fetched_at), quota_limit_reached = false, + quota_verify_required = excluded.quota_verify_required, quota_cooldown_until = null, - quota_verify_required = $13, - updated_at = $14 -where id = $15"; - -pub(super) const UPDATE_IMPORTED_ACCOUNT_PRESERVING_REFRESH_SQL: &str = r" -update accounts -set - email = $1, - chatgpt_account_id = $2, - chatgpt_user_id = $3, - label = $4, - plan_type = $5, - access_token = $6, - access_token_expires_at = $7, - status = $8, - quota_json = coalesce($9, quota_json), - quota_fetched_at = case when $10 is null then quota_fetched_at else $11 end, - quota_limit_reached = false, - quota_cooldown_until = null, - quota_verify_required = $12, - updated_at = $13 -where id = $14"; - -pub(super) const APPLY_IMPORTED_QUOTA_STATE_SQL: &str = r" -update accounts -set - quota_json = coalesce($1, quota_json), - quota_fetched_at = case when $2 is null then quota_fetched_at else $3 end, - plan_type = coalesce($4, plan_type), - quota_limit_reached = false, - quota_cooldown_until = null, - quota_verify_required = $5, - updated_at = $6 -where id = $7"; + updated_at = excluded.updated_at"; pub(super) const DELETE_ACCOUNT_SQL: &str = "delete from accounts where id = $1"; diff --git a/backend/src/fleet/store/write.rs b/backend/src/fleet/store/write.rs index 09bf4d7e9..1a015b278 100644 --- a/backend/src/fleet/store/write.rs +++ b/backend/src/fleet/store/write.rs @@ -103,11 +103,11 @@ impl PgAccountStore { Ok(result.rows_affected() > 0) } - /// 通过导入数据更新已有账号。 - pub async fn update_from_import( + /// 用单条原子语句新建或更新导入账号的完整状态。 + pub async fn upsert_from_import( &self, - update: ImportedAccountUpdate, - ) -> PgAccountStoreResult { + update: ImportedAccountUpsert, + ) -> PgAccountStoreResult<()> { let now = Utc::now(); let refresh_token = update .account @@ -121,45 +121,26 @@ impl PgAccountStore { .map(sqlx::types::Json); let quota_fetched_at = update.quota_fetched_at; - let result = if let Some(refresh_token) = refresh_token { - sqlx::query(UPDATE_IMPORTED_ACCOUNT_WITH_REFRESH_SQL) - .bind(&update.account.email) - .bind(&update.account.account_id) - .bind(&update.account.user_id) - .bind(&update.account.label) - .bind(&update.account.plan_type) - .bind(update.account.access_token.expose_secret()) - .bind(refresh_token) - .bind(update.account.access_token_expires_at) - .bind(status_to_db(update.account.status)) - .bind("a_json) - .bind(quota_fetched_at) - .bind(quota_fetched_at) - .bind(update.quota_verify_required) - .bind(now) - .bind(&update.account.id) - .execute(&self.pool) - .await? - } else { - sqlx::query(UPDATE_IMPORTED_ACCOUNT_PRESERVING_REFRESH_SQL) - .bind(&update.account.email) - .bind(&update.account.account_id) - .bind(&update.account.user_id) - .bind(&update.account.label) - .bind(&update.account.plan_type) - .bind(update.account.access_token.expose_secret()) - .bind(update.account.access_token_expires_at) - .bind(status_to_db(update.account.status)) - .bind("a_json) - .bind(quota_fetched_at) - .bind(quota_fetched_at) - .bind(update.quota_verify_required) - .bind(now) - .bind(&update.account.id) - .execute(&self.pool) - .await? - }; - Ok(result.rows_affected() > 0) + sqlx::query(UPSERT_IMPORTED_ACCOUNT_SQL) + .bind(&update.account.id) + .bind(&update.account.email) + .bind(&update.account.account_id) + .bind(&update.account.user_id) + .bind(&update.account.label) + .bind(&update.account.plan_type) + .bind(update.account.access_token.expose_secret()) + .bind(refresh_token) + .bind(update.account.access_token_expires_at) + .bind(update.next_refresh_at) + .bind(status_to_db(update.account.status)) + .bind(quota_json) + .bind(quota_fetched_at) + .bind(update.quota_verify_required) + .bind(update.account.added_at.unwrap_or(now)) + .bind(now) + .execute(&self.pool) + .await?; + Ok(()) } /// 设置下一次刷新时间。 @@ -318,34 +299,6 @@ where id = $4", Ok(result.rows_affected() > 0) } - /// 应用导入的配额状态。 - pub async fn apply_imported_quota_state( - &self, - account_id: &str, - quota_json: Option<&str>, - quota_fetched_at: Option>, - quota_verify_required: bool, - ) -> PgAccountStoreResult { - let now = Utc::now(); - let fetched = quota_fetched_at; - let plan_type = quota_json.and_then(quota_plan_type); - let quota_json = quota_json - .map(serde_json::from_str::) - .transpose()? - .map(sqlx::types::Json); - let result = sqlx::query(APPLY_IMPORTED_QUOTA_STATE_SQL) - .bind(quota_json) - .bind(fetched) - .bind(fetched) - .bind(&plan_type) - .bind(quota_verify_required) - .bind(now) - .bind(account_id) - .execute(&self.pool) - .await?; - Ok(result.rows_affected() > 0) - } - /// 删除单个账号。 pub async fn delete(&self, account_id: &str) -> PgAccountStoreResult { let result = sqlx::query(DELETE_ACCOUNT_SQL) diff --git a/backend/src/infra/database.rs b/backend/src/infra/database.rs index 6ce752bcb..6d970a3ae 100644 --- a/backend/src/infra/database.rs +++ b/backend/src/infra/database.rs @@ -2,9 +2,9 @@ use std::time::Duration; -use sqlx::{postgres::PgPoolOptions, PgPool, Postgres, Transaction}; +use sha2::{Digest, Sha256}; +use sqlx::{postgres::PgPoolOptions, PgPool, Postgres, Row, Transaction}; -const CURRENT_SCHEMA_VERSION: i64 = 1; const MIGRATIONS: &[Migration] = &[Migration { version: 1, name: "initial", @@ -17,6 +17,18 @@ struct Migration { sql: &'static str, } +struct AppliedMigration { + version: i64, + name: String, + checksum: String, +} + +impl Migration { + fn checksum(&self) -> String { + hex::encode(Sha256::digest(self.sql.as_bytes())) + } +} + /// 连接 PostgreSQL 并应用未执行的迁移。 pub async fn connect(database_url: &str) -> Result { let pool = PgPoolOptions::new() @@ -35,19 +47,18 @@ pub async fn ping(pool: &PgPool) -> Result<(), sqlx::Error> { /// 将 PostgreSQL schema 推进到当前版本。 pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { + validate_migration_catalog()?; + let mut tx = pool.begin().await?; ensure_migrations_table(&mut tx).await?; - let applied_versions = applied_migration_versions(&mut tx).await?; - if applied_versions.is_empty() { + let applied = applied_migrations(&mut tx).await?; + if applied.is_empty() { reject_unversioned_existing_schema(&mut tx).await?; } - validate_applied_migrations(&applied_versions)?; + validate_applied_migrations(&applied)?; - for migration in MIGRATIONS { - if applied_versions.contains(&migration.version) { - continue; - } + for migration in MIGRATIONS.iter().skip(applied.len()) { sqlx::raw_sql(migration.sql).execute(&mut *tx).await?; record_migration(&mut tx, migration).await?; } @@ -55,11 +66,32 @@ pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::Error> { tx.commit().await } +fn validate_migration_catalog() -> Result<(), sqlx::Error> { + let mut previous = 0; + for migration in MIGRATIONS { + if migration.version <= previous { + return Err(sqlx::Error::Protocol(format!( + "PostgreSQL migration versions must be strictly increasing: {} follows {previous}", + migration.version + ))); + } + if migration.name.trim().is_empty() { + return Err(sqlx::Error::Protocol(format!( + "PostgreSQL migration {} has an empty name", + migration.version + ))); + } + previous = migration.version; + } + Ok(()) +} + async fn ensure_migrations_table(tx: &mut Transaction<'_, Postgres>) -> Result<(), sqlx::Error> { sqlx::query( "create table if not exists schema_migrations ( version bigint not null check (version > 0), name text not null, + checksum text not null, applied_at timestamptz not null default now(), primary key (version) )", @@ -69,31 +101,49 @@ async fn ensure_migrations_table(tx: &mut Transaction<'_, Postgres>) -> Result<( Ok(()) } -async fn applied_migration_versions( +async fn applied_migrations( tx: &mut Transaction<'_, Postgres>, -) -> Result, sqlx::Error> { - let rows: Vec<(i64,)> = - sqlx::query_as("select version from schema_migrations order by version") +) -> Result, sqlx::Error> { + let rows = + sqlx::query("select version, name, checksum from schema_migrations order by version") .fetch_all(&mut **tx) .await?; - Ok(rows.into_iter().map(|row| row.0).collect()) + Ok(rows + .into_iter() + .map(|row| AppliedMigration { + version: row.get("version"), + name: row.get("name"), + checksum: row.get("checksum"), + }) + .collect()) } -fn validate_applied_migrations(applied_versions: &[i64]) -> Result<(), sqlx::Error> { - for version in applied_versions { - if !MIGRATIONS - .iter() - .any(|migration| migration.version == *version) - { - return Err(unsupported_schema_version(*version)); - } +fn validate_applied_migrations(applied: &[AppliedMigration]) -> Result<(), sqlx::Error> { + if applied.len() > MIGRATIONS.len() { + return Err(unsupported_schema_version( + applied.last().map_or(0, |migration| migration.version), + )); } - if let Some(version) = applied_versions - .last() - .copied() - .filter(|version| *version > CURRENT_SCHEMA_VERSION) - { - return Err(unsupported_schema_version(version)); + + for (index, applied_migration) in applied.iter().enumerate() { + let expected = &MIGRATIONS[index]; + if applied_migration.version != expected.version { + return Err(unsupported_schema_version(applied_migration.version)); + } + if applied_migration.name != expected.name { + return Err(sqlx::Error::Protocol(format!( + "PostgreSQL migration {} name mismatch: database has {:?}, binary expects {:?}", + expected.version, applied_migration.name, expected.name + ))); + } + + let expected_checksum = expected.checksum(); + if applied_migration.checksum != expected_checksum { + return Err(sqlx::Error::Protocol(format!( + "PostgreSQL migration {} checksum mismatch", + expected.version + ))); + } } Ok(()) } @@ -123,18 +173,24 @@ async fn record_migration( migration: &Migration, ) -> Result<(), sqlx::Error> { sqlx::query( - "insert into schema_migrations (version, name, applied_at) - values ($1, $2, now())", + "insert into schema_migrations (version, name, checksum, applied_at) + values ($1, $2, $3, now())", ) .bind(migration.version) .bind(migration.name) + .bind(migration.checksum()) .execute(&mut **tx) .await?; Ok(()) } +fn current_schema_version() -> i64 { + MIGRATIONS.last().map_or(0, |migration| migration.version) +} + fn unsupported_schema_version(version: i64) -> sqlx::Error { sqlx::Error::Protocol(format!( - "unsupported PostgreSQL schema version {version}; expected {CURRENT_SCHEMA_VERSION}" + "unsupported PostgreSQL schema version {version}; expected {}", + current_schema_version() )) } diff --git a/backend/src/infra/migrations/0001_initial.sql b/backend/src/infra/migrations/0001_initial.sql index f16e15616..8cbf6ae7e 100644 --- a/backend/src/infra/migrations/0001_initial.sql +++ b/backend/src/infra/migrations/0001_initial.sql @@ -60,7 +60,6 @@ create table accounts ( updated_at timestamptz not null ); -create index idx_accounts_status on accounts(status); create index idx_accounts_added_id on accounts(added_at desc, id desc); create unique index ux_accounts_chatgpt_identity on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, '')) @@ -97,24 +96,6 @@ create table account_usage ( last_used_at timestamptz ); -create index idx_account_usage_last_used_account - on account_usage(last_used_at desc, account_id desc); - -create table account_model_usage ( - account_id text not null references accounts(id) on delete cascade, - model text not null, - request_count bigint not null default 0 check (request_count >= 0), - error_count bigint not null default 0 check (error_count >= 0), - input_tokens bigint not null default 0 check (input_tokens >= 0), - output_tokens bigint not null default 0 check (output_tokens >= 0), - cached_tokens bigint not null default 0 check (cached_tokens >= 0), - last_used_at timestamptz, - primary key (account_id, model) -); - -create index idx_account_model_usage_last_used - on account_model_usage(last_used_at desc, account_id, model); - create table usage_records ( id text primary key, request_id text, @@ -234,9 +215,6 @@ create table request_time_buckets ( primary key (bucket_start, provider, account_id, model, service_tier) ); -create index idx_request_time_buckets_model - on request_time_buckets(model, bucket_start); - create table account_cookies ( id text primary key, account_id text not null references accounts(id) on delete cascade, diff --git a/backend/src/models/service.rs b/backend/src/models/service.rs index 386ec91cf..5c17217b0 100644 --- a/backend/src/models/service.rs +++ b/backend/src/models/service.rs @@ -204,6 +204,15 @@ impl ModelService { return Err(ModelServiceError::NoAccounts); } + let previous_snapshots = store + .list_plan_snapshots() + .await + .map_err(map_load_snapshots_error)? + .into_iter() + .map(|snapshot| (snapshot.plan_type.clone(), snapshot)) + .collect::>(); + let mut next_snapshots = BTreeMap::new(); + let mut result = ModelRefreshResult { refreshed_plans: 0, model_count: 0, @@ -223,11 +232,17 @@ impl ModelService { Ok(entries) if !entries.is_empty() => entries, Ok(_) => { result.failed_plans += 1; + if let Some(snapshot) = previous_snapshots.get(&plan_account.plan_type) { + next_snapshots.insert(plan_account.plan_type.clone(), snapshot.clone()); + } continue; } Err(error) => { tracing::warn!(error = %error, plan_type = %plan_account.plan_type, "刷新后端模型失败"); result.failed_plans += 1; + if let Some(snapshot) = previous_snapshots.get(&plan_account.plan_type) { + next_snapshots.insert(plan_account.plan_type.clone(), snapshot.clone()); + } continue; } }; @@ -236,13 +251,13 @@ impl ModelService { ModelPlanSnapshot::from_backend_values(plan_account.plan_type.clone(), entries); if snapshot.models.is_empty() { result.failed_plans += 1; + if let Some(snapshot) = previous_snapshots.get(&plan_account.plan_type) { + next_snapshots.insert(plan_account.plan_type.clone(), snapshot.clone()); + } continue; } result.model_count += snapshot.models.len(); - store - .replace_plan_snapshot(&snapshot) - .await - .map_err(map_store_snapshot_error)?; + next_snapshots.insert(plan_account.plan_type.clone(), snapshot); result.refreshed_plans += 1; } @@ -250,7 +265,12 @@ impl ModelService { return Err(ModelServiceError::AllPlansFailed(result)); } - self.reload_from_store().await?; + let next_snapshots = next_snapshots.into_values().collect::>(); + store + .replace_plan_snapshots(&next_snapshots) + .await + .map_err(map_store_snapshot_error)?; + self.replace_snapshots(next_snapshots); Ok(result) } diff --git a/backend/src/models/store.rs b/backend/src/models/store.rs index 26708a68e..7ec3108ab 100644 --- a/backend/src/models/store.rs +++ b/backend/src/models/store.rs @@ -27,10 +27,10 @@ pub type ModelSnapshotStoreResult = Result; /// 模型快照存储端口。 #[async_trait] pub trait ModelSnapshotStore: Send + Sync + 'static { - /// 用单个计划快照替换同名快照。 - async fn replace_plan_snapshot( + /// 用完整计划集合原子替换 Redis 快照。 + async fn replace_plan_snapshots( &self, - snapshot: &ModelPlanSnapshot, + snapshots: &[ModelPlanSnapshot], ) -> ModelSnapshotStoreResult<()>; /// 列出所有计划快照。 @@ -59,22 +59,35 @@ impl RedisModelSnapshotStore { #[async_trait] impl ModelSnapshotStore for RedisModelSnapshotStore { - async fn replace_plan_snapshot( + async fn replace_plan_snapshots( &self, - snapshot: &ModelPlanSnapshot, + snapshots: &[ModelPlanSnapshot], ) -> ModelSnapshotStoreResult<()> { - let value = serde_json::to_string(&StoredPlanSnapshot { - models: snapshot.models.clone(), - fetched_at: Utc::now(), - }) - .map_err(store_error)?; + let fetched_at = Utc::now(); + let mut values = Vec::with_capacity(snapshots.len()); + for snapshot in snapshots { + let value = serde_json::to_string(&StoredPlanSnapshot { + models: snapshot.models.clone(), + fetched_at, + }) + .map_err(store_error)?; + values.push((&snapshot.plan_type, value)); + } + + let key = self.redis.key("models:plan_snapshots"); let mut connection = self.redis.manager(); - let _: usize = connection - .hset( - self.redis.key("models:plan_snapshots"), - &snapshot.plan_type, - value, - ) + let mut transaction = redis::pipe(); + transaction.atomic().cmd("DEL").arg(&key).ignore(); + for (plan_type, value) in values { + transaction + .cmd("HSET") + .arg(&key) + .arg(plan_type) + .arg(value) + .ignore(); + } + let _: () = transaction + .query_async(&mut connection) .await .map_err(store_error)?; Ok(()) diff --git a/backend/src/telemetry/account_usage/store.rs b/backend/src/telemetry/account_usage/store.rs index 811dadfe7..e568c0b90 100644 --- a/backend/src/telemetry/account_usage/store.rs +++ b/backend/src/telemetry/account_usage/store.rs @@ -1,6 +1,6 @@ //! PostgreSQL 账号用量查询存储。 -use std::{collections::HashMap, num::NonZeroU32}; +use std::collections::HashMap; use async_trait::async_trait; use chrono::{DateTime, Utc}; @@ -9,8 +9,6 @@ use thiserror::Error; use crate::infra::format::nonnegative_i64_to_u64; -use super::should_reset_usage_window; - const RECORD_USAGE_SQL: &str = r" insert into account_usage ( account_id, request_count, empty_response_count, @@ -44,19 +42,6 @@ on conflict(account_id) do update set window_started_at = coalesce(account_usage.window_started_at, excluded.window_started_at), last_used_at = excluded.last_used_at"; -const RECORD_MODEL_USAGE_SQL: &str = r" -insert into account_model_usage ( - account_id, model, request_count, error_count, - input_tokens, output_tokens, cached_tokens, last_used_at -) values ($1, $2, $3, $4, $5, $6, $7, $8) -on conflict(account_id, model) do update set - request_count = account_model_usage.request_count + excluded.request_count, - error_count = account_model_usage.error_count + excluded.error_count, - input_tokens = account_model_usage.input_tokens + excluded.input_tokens, - output_tokens = account_model_usage.output_tokens + excluded.output_tokens, - cached_tokens = account_model_usage.cached_tokens + excluded.cached_tokens, - last_used_at = excluded.last_used_at"; - const SYNC_RUNTIME_WINDOW_SQL: &str = r" insert into account_usage ( account_id, window_request_count, window_input_tokens, window_output_tokens, @@ -80,32 +65,41 @@ where account_usage.window_reset_at is null or account_usage.window_reset_at <= excluded.window_reset_at or account_usage.window_reset_at <= $13"; -const SYNC_RATE_LIMIT_WINDOW_RESET_SQL: &str = r" -insert into account_usage ( - account_id, window_request_count, window_input_tokens, window_output_tokens, - window_cached_tokens, window_image_input_tokens, window_image_output_tokens, - window_image_request_count, window_image_request_failed_count, - window_started_at, window_reset_at, limit_window_seconds -) values ($1, 0, 0, 0, 0, 0, 0, 0, 0, $2, $3, $4) -on conflict(account_id) do update set - window_request_count = 0, - window_input_tokens = 0, - window_output_tokens = 0, - window_cached_tokens = 0, - window_image_input_tokens = 0, - window_image_output_tokens = 0, - window_image_request_count = 0, - window_image_request_failed_count = 0, - window_started_at = excluded.window_started_at, - window_reset_at = excluded.window_reset_at, - limit_window_seconds = coalesce(excluded.limit_window_seconds, account_usage.limit_window_seconds)"; - const SYNC_RATE_LIMIT_WINDOW_SQL: &str = r" insert into account_usage (account_id, window_reset_at, limit_window_seconds) values ($1, $2, $3) on conflict(account_id) do update set - window_reset_at = excluded.window_reset_at, - limit_window_seconds = coalesce(excluded.limit_window_seconds, account_usage.limit_window_seconds)"; + ( + window_request_count, window_input_tokens, window_output_tokens, window_cached_tokens, + window_image_input_tokens, window_image_output_tokens, + window_image_request_count, window_image_request_failed_count, + window_started_at, window_reset_at, limit_window_seconds + ) = ( + select + case when should_reset then 0 else account_usage.window_request_count end, + case when should_reset then 0 else account_usage.window_input_tokens end, + case when should_reset then 0 else account_usage.window_output_tokens end, + case when should_reset then 0 else account_usage.window_cached_tokens end, + case when should_reset then 0 else account_usage.window_image_input_tokens end, + case when should_reset then 0 else account_usage.window_image_output_tokens end, + case when should_reset then 0 else account_usage.window_image_request_count end, + case when should_reset then 0 else account_usage.window_image_request_failed_count end, + case when should_reset then $4 else account_usage.window_started_at end, + excluded.window_reset_at, + coalesce(excluded.limit_window_seconds, account_usage.limit_window_seconds) + from ( + values ( + account_usage.window_reset_at is not null + and account_usage.window_reset_at <> excluded.window_reset_at + and abs(extract(epoch from account_usage.window_reset_at - excluded.window_reset_at)) >= + case + when coalesce(excluded.limit_window_seconds, account_usage.limit_window_seconds, 0) > 0 + then coalesce(excluded.limit_window_seconds, account_usage.limit_window_seconds) / 2 + else 3600 + end + ) + ) as decision(should_reset) + )"; const LIST_USAGE_SELECT_SQL: &str = r" select @@ -192,15 +186,6 @@ pub struct AccountUsageDelta { pub image_request_failures: u64, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub struct AccountModelUsageDelta { - pub requests: u64, - pub errors: u64, - pub input_tokens: u64, - pub output_tokens: u64, - pub cached_tokens: u64, -} - #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct AccountUsageSnapshot { pub request_count: u64, @@ -251,13 +236,6 @@ pub trait AccountUsageStore: Send + Sync + 'static { usage: AccountUsageDelta, ) -> Result<(), AccountUsageStoreError>; - async fn record_model_usage_delta( - &self, - account_id: &str, - model: &str, - usage: AccountModelUsageDelta, - ) -> Result<(), AccountUsageStoreError>; - async fn sync_runtime_window( &self, account_id: &str, @@ -378,33 +356,6 @@ impl PgAccountUsageStore { &self.pool } - /// 将每个账号的模型用量统计裁剪为最近使用的指定行数。 - pub async fn trim_model_usage_to_limit( - &self, - max_models_per_account: NonZeroU32, - ) -> PgAccountUsageStoreResult { - let result = sqlx::query( - r" - delete from account_model_usage as target - using ( - select account_id, model - from ( - select account_id, model, - row_number() over (partition by account_id order by last_used_at desc nulls last, model) as position - from account_model_usage - ) as ranked - where position > $1 - ) as expired - where target.account_id = expired.account_id - and target.model = expired.model - ", - ) - .bind(i64::from(max_models_per_account.get())) - .execute(&self.pool) - .await?; - Ok(result.rows_affected()) - } - async fn load_snapshots( &self, account_ids: &[String], @@ -480,30 +431,6 @@ where account_id in (", Ok(()) } - async fn write_model_usage_delta( - &self, - account_id: &str, - model: &str, - delta: AccountModelUsageDelta, - ) -> PgAccountUsageStoreResult<()> { - let model = model.trim(); - if model.is_empty() { - return Ok(()); - } - sqlx::query(RECORD_MODEL_USAGE_SQL) - .bind(account_id) - .bind(model) - .bind(u64_to_i64_saturating(delta.requests)) - .bind(u64_to_i64_saturating(delta.errors)) - .bind(u64_to_i64_saturating(delta.input_tokens)) - .bind(u64_to_i64_saturating(delta.output_tokens)) - .bind(u64_to_i64_saturating(delta.cached_tokens)) - .bind(Utc::now()) - .execute(&self.pool) - .await?; - Ok(()) - } - async fn replace_runtime_window( &self, account_id: &str, @@ -534,40 +461,14 @@ where account_id in (", reset_at: DateTime, limit_window_seconds: Option, ) -> PgAccountUsageStoreResult<()> { - let existing = sqlx::query( - "select window_reset_at, limit_window_seconds from account_usage where account_id = $1", - ) - .bind(account_id) - .fetch_optional(&self.pool) - .await?; - let should_reset = existing - .as_ref() - .map(|row| { - should_reset_usage_window( - row.get("window_reset_at"), - optional_positive_i64_to_u64(row.get("limit_window_seconds")), - reset_at, - limit_window_seconds, - ) - }) - .unwrap_or_default(); let limit_window_seconds = limit_window_seconds.map(u64_to_i64_saturating); - if should_reset { - sqlx::query(SYNC_RATE_LIMIT_WINDOW_RESET_SQL) - .bind(account_id) - .bind(Utc::now()) - .bind(reset_at) - .bind(limit_window_seconds) - .execute(&self.pool) - .await?; - } else { - sqlx::query(SYNC_RATE_LIMIT_WINDOW_SQL) - .bind(account_id) - .bind(reset_at) - .bind(limit_window_seconds) - .execute(&self.pool) - .await?; - } + sqlx::query(SYNC_RATE_LIMIT_WINDOW_SQL) + .bind(account_id) + .bind(reset_at) + .bind(limit_window_seconds) + .bind(Utc::now()) + .execute(&self.pool) + .await?; Ok(()) } @@ -631,17 +532,6 @@ impl AccountUsageStore for PgAccountUsageStore { .map_err(Into::into) } - async fn record_model_usage_delta( - &self, - account_id: &str, - model: &str, - usage: AccountModelUsageDelta, - ) -> Result<(), AccountUsageStoreError> { - self.write_model_usage_delta(account_id, model, usage) - .await - .map_err(Into::into) - } - async fn sync_runtime_window( &self, account_id: &str, diff --git a/backend/src/telemetry/buckets/store.rs b/backend/src/telemetry/buckets/store.rs index 471bebf43..1031704b2 100644 --- a/backend/src/telemetry/buckets/store.rs +++ b/backend/src/telemetry/buckets/store.rs @@ -113,7 +113,7 @@ on conflict (bucket_start, provider, account_id, model, service_tier) do update .bind(dimension(event.provider.as_deref())) .bind(dimension(event.account_id.as_deref())) .bind(dimension(event.model.as_deref())) - .bind(dimension(event.service_tier.as_deref())) + .bind(dimension(None)) .bind(Utc::now()) .execute(&mut **tx) .await?; diff --git a/backend/src/telemetry/usage/store.rs b/backend/src/telemetry/usage/store.rs index 987660d11..d5698d771 100644 --- a/backend/src/telemetry/usage/store.rs +++ b/backend/src/telemetry/usage/store.rs @@ -299,6 +299,8 @@ pub(super) fn push_filter(builder: &mut QueryBuilder, filter: &UsageRe builder.push_bind(search); builder.push(" or response_id = "); builder.push_bind(search); + builder.push(" or upstream_request_id = "); + builder.push_bind(search); builder.push(")"); } if let Some(start_time) = filter.start_time { diff --git a/backend/src/upstream/openai/transport/websocket.rs b/backend/src/upstream/openai/transport/websocket.rs index 0532cc182..b0d540a97 100644 --- a/backend/src/upstream/openai/transport/websocket.rs +++ b/backend/src/upstream/openai/transport/websocket.rs @@ -776,7 +776,7 @@ async fn execute_fresh_pooled_response_create_request( return Err(error.into()); } - let now = Instant::now(); + let now = tokio::time::Instant::now(); let metadata = websocket_connection_metadata(&response); let (exchange, mut websocket, metadata, continuation, terminal) = match collect_websocket_response( @@ -835,7 +835,7 @@ async fn execute_fresh_pooled_response_create_request_stream( return Err(error.into()); } - let now = Instant::now(); + let now = tokio::time::Instant::now(); let mut metadata = websocket_connection_metadata(&response); let prefetched_frames = if should_prefetch_until_output_or_terminal(request) { match prefetch_stream_frames_until_output_or_terminal( diff --git a/backend/src/upstream/openai/transport/websocket_frames.rs b/backend/src/upstream/openai/transport/websocket_frames.rs index e44aecc5d..673606d50 100644 --- a/backend/src/upstream/openai/transport/websocket_frames.rs +++ b/backend/src/upstream/openai/transport/websocket_frames.rs @@ -454,7 +454,7 @@ pub(super) fn reusable_websocket_metadata( pub(super) struct WebSocketStreamPoolReturn { pub(super) lease: WebSocketPoolLease, - pub(super) created_at: Instant, + pub(super) created_at: tokio::time::Instant, pub(super) continuation: WebSocketContinuationState, } diff --git a/backend/src/upstream/openai/transport/websocket_pool.rs b/backend/src/upstream/openai/transport/websocket_pool.rs index 76926dead..d065a42e9 100644 --- a/backend/src/upstream/openai/transport/websocket_pool.rs +++ b/backend/src/upstream/openai/transport/websocket_pool.rs @@ -1,14 +1,10 @@ //! Codex WebSocket 连接池。 -use std::{ - collections::HashMap, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{collections::HashMap, sync::Arc, time::Duration}; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use tokio::sync::Mutex; +use tokio::{sync::Mutex, time::Instant}; use uuid::Uuid; use super::websocket_pump::{PumpKeepalive, PumpedWebSocket}; diff --git a/backend/src/upstream/openai/transport/websocket_pump.rs b/backend/src/upstream/openai/transport/websocket_pump.rs index 3463f0efd..9951bcf37 100644 --- a/backend/src/upstream/openai/transport/websocket_pump.rs +++ b/backend/src/upstream/openai/transport/websocket_pump.rs @@ -16,14 +16,14 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, }; -use std::time::{Duration, Instant}; +use std::time::Duration; use futures::{SinkExt, StreamExt}; use tokio::{ net::TcpStream, sync::{mpsc, oneshot}, task::JoinHandle, - time::MissedTickBehavior, + time::{Instant, MissedTickBehavior}, }; use tokio_tungstenite::{tungstenite::Message, MaybeTlsStream, WebSocketStream}; diff --git a/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs b/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs index f5c4d59af..7a45dd945 100644 --- a/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs +++ b/backend/tests/api/admin/accounts_routes/lifecycle_routes.rs @@ -131,14 +131,6 @@ async fn seed_account_related_rows(pool: &PgPool, account_id: &str) { .execute(pool) .await .unwrap(); - sqlx::query( - "insert into account_model_usage (account_id, model, request_count) values ($1, $2, 1)", - ) - .bind(account_id) - .bind("gpt-5.5") - .execute(pool) - .await - .unwrap(); sqlx::query("insert into account_cookies (id, account_id, domain, name, value, updated_at) values ($1, $2, $3, $4, $5, $6)") .bind("cookie_lifecycle") .bind(account_id) @@ -162,12 +154,6 @@ async fn assert_account_related_rows_deleted(pool: &PgPool, account_id: &str) { .fetch_one(pool) .await .unwrap(); - let model_usage: i64 = - sqlx::query_scalar("select count(*) from account_model_usage where account_id = $1") - .bind(account_id) - .fetch_one(pool) - .await - .unwrap(); let cookies: i64 = sqlx::query_scalar("select count(*) from account_cookies where account_id = $1") .bind(account_id) @@ -176,7 +162,6 @@ async fn assert_account_related_rows_deleted(pool: &PgPool, account_id: &str) { .unwrap(); assert_eq!(accounts, 0); assert_eq!(usage, 0); - assert_eq!(model_usage, 0); assert_eq!(cookies, 0); } diff --git a/backend/tests/api/admin/accounts_routes/list.rs b/backend/tests/api/admin/accounts_routes/list.rs index 24ebcf2b2..0655eaa73 100644 --- a/backend/tests/api/admin/accounts_routes/list.rs +++ b/backend/tests/api/admin/accounts_routes/list.rs @@ -252,32 +252,6 @@ async fn admin_accounts_list_should_include_usage_quota_and_model_stats() { .execute(&pool) .await .unwrap(); - sqlx::query( - "insert into account_model_usage (account_id, model, request_count, input_tokens, output_tokens, cached_tokens, last_used_at) values ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind("acct_stats") - .bind("gpt-5.5") - .bind(2) - .bind(3_500_100) - .bind(13_920) - .bind(3_400_010) - .bind(crate::support::storage::timestamp("2026-06-23T08:51:13Z")) - .execute(&pool) - .await - .unwrap(); - sqlx::query( - "insert into account_model_usage (account_id, model, request_count, input_tokens, output_tokens, cached_tokens, last_used_at) values ($1, $2, $3, $4, $5, $6, $7)", - ) - .bind("acct_stats") - .bind("gpt-5") - .bind(1) - .bind(200) - .bind(30) - .bind(20) - .bind(crate::support::storage::timestamp("2026-06-23T08:52:13Z")) - .execute(&pool) - .await - .unwrap(); sqlx::query( "insert into request_time_buckets (bucket_start, provider, account_id, model, service_tier, success_count, input_tokens, output_tokens, cached_tokens, updated_at) values ($1, 'openai', $2, $3, '__unknown__', $4, $5, $6, $7, $8)", ) diff --git a/backend/tests/api/admin/accounts_routes/probe_routes.rs b/backend/tests/api/admin/accounts_routes/probe_routes.rs index 2c6940c70..32ec7fc45 100644 --- a/backend/tests/api/admin/accounts_routes/probe_routes.rs +++ b/backend/tests/api/admin/accounts_routes/probe_routes.rs @@ -241,7 +241,7 @@ async fn seed_model_snapshot(state: &AdminAccountsTestState, plan_type: &str) { ], ); codex_proxy_rs::models::store::RedisModelSnapshotStore::new(state.redis.clone()) - .replace_plan_snapshot(&snapshot) + .replace_plan_snapshots(&[snapshot]) .await .unwrap(); } @@ -261,7 +261,7 @@ async fn seed_single_model_snapshot( }], ); codex_proxy_rs::models::store::RedisModelSnapshotStore::new(state.redis.clone()) - .replace_plan_snapshot(&snapshot) + .replace_plan_snapshots(&[snapshot]) .await .unwrap(); } diff --git a/backend/tests/api/admin/auth_routes/session.rs b/backend/tests/api/admin/auth_routes/session.rs index aa20d7bbe..83a20a214 100644 --- a/backend/tests/api/admin/auth_routes/session.rs +++ b/backend/tests/api/admin/auth_routes/session.rs @@ -37,8 +37,14 @@ async fn admin_login_should_issue_http_only_session_cookie() { .get("set-cookie") .and_then(|value| value.to_str().ok()) .map(ToString::to_string); + let cache_control = response + .headers() + .get("cache-control") + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); assert_eq!(status, StatusCode::OK, "login status"); + assert_eq!(cache_control.as_deref(), Some("no-store")); let body = response_json(response).await; let cookie = cookie.expect("login should set admin session cookie"); assert!(cookie.starts_with("cpr_admin_session=")); @@ -62,6 +68,52 @@ async fn admin_login_should_issue_http_only_session_cookie() { assert_eq!(usage_records_response.status(), StatusCode::OK); } +#[tokio::test] +async fn admin_logout_should_revoke_session_and_clear_cookie() { + let (app, _dir, _pool) = + admin_login_test_app("admin-logout-revokes-session", "correct-password").await; + let login = post_login(&app, "correct-password").await; + let cookie = login + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(';').next()) + .unwrap() + .to_string(); + + let logout = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/admin/logout") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::OK); + assert!(logout + .headers() + .get("set-cookie") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.contains("Max-Age=0"))); + + let status = app + .oneshot( + Request::builder() + .uri("/api/admin/auth/status") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = response_json(status).await; + assert_eq!(body["data"]["authenticated"], false); +} + #[tokio::test] async fn admin_login_should_reject_client_api_key_as_password_or_authorization() { let (app, _dir, _pool) = diff --git a/backend/tests/api/admin/system_routes/mod.rs b/backend/tests/api/admin/system_routes/mod.rs index b71d0005a..99649e695 100644 --- a/backend/tests/api/admin/system_routes/mod.rs +++ b/backend/tests/api/admin/system_routes/mod.rs @@ -1258,8 +1258,14 @@ async fn wait_for_operation_status(app: &axum::Router, expected: &str) -> Value ) .await .unwrap(); - let data = response_json(response).await["data"].clone(); - assert_eq!(data["operation"]["status"], expected); + let status_code = response.status(); + let body = response_json(response).await; + assert_eq!(status_code, StatusCode::OK, "body={body}"); + let data = body["data"].clone(); + assert_eq!( + data["operation"]["status"], expected, + "update status response={body}" + ); data } diff --git a/backend/tests/api/client/responses.rs b/backend/tests/api/client/responses.rs index 7d8b83fba..1eb0211eb 100644 --- a/backend/tests/api/client/responses.rs +++ b/backend/tests/api/client/responses.rs @@ -75,6 +75,29 @@ async fn responses_route_should_accept_body_larger_than_axum_default_limit() { assert_eq!(response.status(), StatusCode::NOT_FOUND); } +#[tokio::test] +async fn responses_route_should_reject_body_over_proxy_limit() { + let (app, api_key, _dir) = test_app_with_client_api_key().await; + let body = serde_json::json!({ + "model": "gpt-5.5", + "input": "x".repeat(17 * 1024 * 1024), + }) + .to_string(); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from(body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); +} + #[tokio::test] async fn responses_route_should_terminate_generated_stream_errors_with_done_marker() { let (app, api_key, _dir) = test_app_with_client_api_key().await; diff --git a/backend/tests/auth/store.rs b/backend/tests/auth/store.rs index d0b5faa61..3f35d793c 100644 --- a/backend/tests/auth/store.rs +++ b/backend/tests/auth/store.rs @@ -61,3 +61,27 @@ async fn redis_admin_session_store_hashes_tokens_and_uses_ttl() { assert!(store.delete_session("session-secret").await.unwrap()); assert!(!store.validate_session("session-secret").await.unwrap()); } + +#[tokio::test] +async fn redis_admin_session_store_should_reject_expired_session() { + let redis = create_test_redis("admin-session-expired").await; + let store = RedisAdminSessionStore::new(redis.clone()); + store + .create_session("expired-session", "admin_1", Duration::minutes(5)) + .await + .unwrap(); + let mut connection = redis.manager(); + let keys: Vec = redis::cmd("KEYS") + .arg(redis.key("admin:session:*")) + .query_async(&mut connection) + .await + .unwrap(); + let _: bool = redis::cmd("PEXPIREAT") + .arg(&keys[0]) + .arg(1) + .query_async(&mut connection) + .await + .unwrap(); + + assert!(!store.validate_session("expired-session").await.unwrap()); +} diff --git a/backend/tests/bootstrap/import_sqlite/mod.rs b/backend/tests/bootstrap/import_sqlite/mod.rs index 0d42ae618..6f2b769b8 100644 --- a/backend/tests/bootstrap/import_sqlite/mod.rs +++ b/backend/tests/bootstrap/import_sqlite/mod.rs @@ -1,6 +1,9 @@ use std::path::PathBuf; -use codex_proxy_rs::bootstrap::import_sqlite::{import_sqlite, ImportSqliteError}; +use codex_proxy_rs::{ + bootstrap::import_sqlite::{import_sqlite, ImportSqliteError}, + keys::{service::KeyVerifier, store::PgClientKeyStore}, +}; use sqlx::{sqlite::SqliteConnectOptions, sqlite::SqlitePoolOptions, Row}; use crate::support::storage::init_test_db; @@ -23,6 +26,7 @@ async fn import_sqlite_should_split_facts_preserve_retrievable_keys_and_report_d assert_eq!(report.discarded_rows("session_affinities"), 1); assert_eq!(report.discarded_rows("account_refresh_leases"), 1); assert_eq!(report.discarded_rows("model_plan_snapshots"), 1); + assert_eq!(report.discarded_rows("account_model_usage"), 1); assert_eq!(report.cookie_expiration_parse_failures(), 1); let key: String = sqlx::query_scalar("select key from client_api_keys where id = $1") @@ -30,7 +34,13 @@ async fn import_sqlite_should_split_facts_preserve_retrievable_keys_and_report_d .fetch_one(&target) .await .unwrap(); - assert_eq!(key, "sk-old-secret"); + assert_eq!(key, "sk_old_secret"); + let verifier = KeyVerifier::new(PgClientKeyStore::new(target.clone())); + assert_eq!( + verifier.verify(&key).await.unwrap().as_deref(), + Some("key-1") + ); + verifier.flush_pending_last_used().await; let refreshing_account: (String, Option>) = sqlx::query_as("select status, next_refresh_at from accounts where id = $1") @@ -209,7 +219,7 @@ insert into admin_users values insert into admin_sessions values ('session', 'admin', '2026-01-02T00:00:00Z', '2026-01-01T00:00:00Z'); insert into client_api_keys values - ('key-1', 'legacy', 'sk-old', 'sk-old-secret', null, 1, + ('key-1', 'legacy', 'sk_old_secre', 'sk_old_secret', null, 1, '2026-01-01T00:00:00Z', null); insert into runtime_settings values (1, '{}', 3600, 2, 3, 50, 'smart', 'admin-machine-secret', @@ -228,7 +238,12 @@ values ('acct-refreshing', 'refreshing@example.com', 'chatgpt-refreshing', 'user-refreshing', null, 'plus', 'access-refreshing', 'refresh-refreshing', '2026-02-01T00:00:00Z', '2026-01-31T23:00:00Z', 'refreshing', null, null, 0, 0, null, null, - '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'); + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z'); +insert into account_model_usage + (account_id, model, request_count, error_count, input_tokens, output_tokens, + cached_tokens, last_used_at) +values + ('acct-1', 'gpt-5.5', 1, 0, 10, 2, 4, '2026-01-01T00:01:00Z'); insert into account_cookies values ('cookie-1', 'acct-1', '.chatgpt.com', 'session', 'secret', '/', 'not-a-cookie-date', '2026-01-01T00:00:00Z'); diff --git a/backend/tests/bootstrap/tasks/mod.rs b/backend/tests/bootstrap/tasks/mod.rs index cf3182b8a..e5f78365a 100644 --- a/backend/tests/bootstrap/tasks/mod.rs +++ b/backend/tests/bootstrap/tasks/mod.rs @@ -8,8 +8,8 @@ use codex_proxy_rs::fleet::store::{AccountStore, AccountStoreResult}; use codex_proxy_rs::models::service::ModelService; use codex_proxy_rs::models::types::ModelConfig; use codex_proxy_rs::telemetry::account_usage::store::{ - AccountModelUsageDelta, AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, - AccountUsageStoreError, AccountUsageWindow, + AccountUsageDelta, AccountUsageSnapshot, AccountUsageStore, AccountUsageStoreError, + AccountUsageWindow, }; use codex_proxy_rs::upstream::openai::fingerprint::{PgFingerprintStore, RuntimeFingerprint}; use wiremock::{ @@ -23,6 +23,7 @@ mod cleanup; mod coordinator; mod fingerprint_update; mod model_refresh; +mod retention_trim; struct FakeAccountStore; @@ -97,15 +98,6 @@ impl AccountUsageStore for FakeAccountUsageStore { Ok(()) } - async fn record_model_usage_delta( - &self, - _account_id: &str, - _model: &str, - _usage: AccountModelUsageDelta, - ) -> Result<(), AccountUsageStoreError> { - Ok(()) - } - async fn sync_runtime_window( &self, _account_id: &str, diff --git a/backend/tests/bootstrap/tasks/model_refresh.rs b/backend/tests/bootstrap/tasks/model_refresh.rs index 592587987..b357b48a2 100644 --- a/backend/tests/bootstrap/tasks/model_refresh.rs +++ b/backend/tests/bootstrap/tasks/model_refresh.rs @@ -53,18 +53,15 @@ async fn model_refresh_task_should_sync_model_plan_allowlist_to_account_pool() { Some(upstream), )); let request_usage_records = Arc::new(AtomicUsize::new(0)); - let model_request_usage_records = Arc::new(AtomicUsize::new(0)); let account_store = Arc::new(PlanAccountStore { accounts: vec![ plan_account("acct-plus", "plus"), plan_account("acct-team", "team"), ], request_usage_records: request_usage_records.clone(), - model_request_usage_records: model_request_usage_records.clone(), }); let usage_store = Arc::new(PlanAccountUsageStore { request_usage_records, - model_request_usage_records, }); let account_pool = Arc::new(AccountPoolService::new( account_store.clone(), @@ -95,12 +92,6 @@ async fn model_refresh_task_should_sync_model_plan_allowlist_to_account_pool() { account_store.request_usage_records.load(Ordering::SeqCst), 0 ); - assert_eq!( - account_store - .model_request_usage_records - .load(Ordering::SeqCst), - 0 - ); } fn empty_model_config() -> ModelConfig { @@ -130,14 +121,17 @@ struct InMemorySnapshotStore { #[async_trait] impl ModelSnapshotStore for InMemorySnapshotStore { - async fn replace_plan_snapshot( + async fn replace_plan_snapshots( &self, - snapshot: &ModelPlanSnapshot, + snapshots: &[ModelPlanSnapshot], ) -> ModelSnapshotStoreResult<()> { - self.snapshots - .lock() - .await - .insert(snapshot.plan_type.clone(), snapshot.clone()); + let mut stored = self.snapshots.lock().await; + stored.clear(); + stored.extend( + snapshots + .iter() + .map(|snapshot| (snapshot.plan_type.clone(), snapshot.clone())), + ); Ok(()) } @@ -170,7 +164,6 @@ impl CodexModelCatalogClient for FakeModelCatalogClient { struct PlanAccountStore { accounts: Vec, request_usage_records: Arc, - model_request_usage_records: Arc, } #[async_trait] @@ -224,7 +217,6 @@ impl AccountStore for PlanAccountStore { struct PlanAccountUsageStore { request_usage_records: Arc, - model_request_usage_records: Arc, } #[async_trait] @@ -247,19 +239,6 @@ impl AccountUsageStore for PlanAccountUsageStore { Ok(()) } - async fn record_model_usage_delta( - &self, - _account_id: &str, - _model: &str, - usage: AccountModelUsageDelta, - ) -> Result<(), AccountUsageStoreError> { - if usage.requests > 0 { - self.model_request_usage_records - .fetch_add(1, Ordering::SeqCst); - } - Ok(()) - } - async fn sync_runtime_window( &self, _account_id: &str, diff --git a/backend/tests/bootstrap/tasks/retention_trim.rs b/backend/tests/bootstrap/tasks/retention_trim.rs new file mode 100644 index 000000000..55ce0e048 --- /dev/null +++ b/backend/tests/bootstrap/tasks/retention_trim.rs @@ -0,0 +1,97 @@ +use chrono::{Duration, Utc}; +use codex_proxy_rs::{ + bootstrap::tasks::retention_trim::RetentionTrimTask, + telemetry::{ + buckets::store::PgRequestBucketStore, + ops::{store::PgOpsErrorLogStore, types::OpsErrorLog}, + usage::{store::PgUsageRecordStore, types::UsageRecord}, + }, + upstream::openai::fingerprint::PgFingerprintStore, +}; + +use crate::support::storage::init_test_db; + +#[tokio::test] +async fn retention_trim_task_should_trim_all_growth_tables_in_one_run() { + let (pool, _guard) = init_test_db("retention-trim-task").await; + sqlx::query( + "insert into runtime_settings ( + id, refresh_margin_seconds, refresh_concurrency, + max_concurrent_per_account, request_interval_ms, rotation_strategy, + usage_retention_days, ops_error_retention_days, bucket_retention_days, updated_at + ) values (1, 3600, 2, 1, 0, 'smart', 1, 1, 1, now())", + ) + .execute(&pool) + .await + .unwrap(); + + let usage_store = PgUsageRecordStore::new(pool.clone()); + let ops_store = PgOpsErrorLogStore::new(pool.clone()); + let bucket_store = PgRequestBucketStore::new(pool.clone()); + let fingerprint_store = PgFingerprintStore::new(pool.clone()); + fingerprint_store + .ensure_current_seed(&crate::support::fingerprint::test_fingerprint()) + .await + .unwrap(); + + let now = Utc::now(); + let mut old_usage = UsageRecord::new("request", "old", "acct", "gpt-old", 200); + old_usage.id = "usage_old".to_string(); + old_usage.created_at = now - Duration::days(2); + usage_store.append(&old_usage).await.unwrap(); + let mut current_usage = UsageRecord::new("request", "current", "acct", "gpt-new", 200); + current_usage.id = "usage_current".to_string(); + current_usage.created_at = now; + usage_store.append(¤t_usage).await.unwrap(); + + let mut old_error = OpsErrorLog::new("request", "old error"); + old_error.id = "ops_old".to_string(); + old_error.model = Some("gpt-old".to_string()); + old_error.created_at = now - Duration::days(2); + ops_store.append(&old_error).await.unwrap(); + let mut current_error = OpsErrorLog::new("request", "current error"); + current_error.id = "ops_current".to_string(); + current_error.model = Some("gpt-new".to_string()); + current_error.created_at = now; + ops_store.append(¤t_error).await.unwrap(); + + sqlx::query( + "insert into fingerprint_update_history ( + id, current_fingerprint_id, app_version, build_number, source, created_at + ) + select 'history_' || value, 'current', '1.0.' || value, value::text, 'test', + now() + value * interval '1 microsecond' + from generate_series(1, 101) as value", + ) + .execute(&pool) + .await + .unwrap(); + + RetentionTrimTask::new(usage_store, ops_store, bucket_store, fingerprint_store) + .run_once() + .await; + + let usage_ids: Vec = sqlx::query_scalar("select id from usage_records order by id") + .fetch_all(&pool) + .await + .unwrap(); + let ops_ids: Vec = sqlx::query_scalar("select id from ops_error_logs order by id") + .fetch_all(&pool) + .await + .unwrap(); + let old_buckets: i64 = sqlx::query_scalar( + "select count(*) from request_time_buckets where bucket_start < now() - interval '1 day'", + ) + .fetch_one(&pool) + .await + .unwrap(); + let history_count: i64 = sqlx::query_scalar("select count(*) from fingerprint_update_history") + .fetch_one(&pool) + .await + .unwrap(); + + assert_eq!(usage_ids, vec!["usage_current".to_string()]); + assert_eq!(ops_ids, vec!["ops_current".to_string()]); + assert_eq!(old_buckets, 0); + assert_eq!(history_count, 100); +} diff --git a/backend/tests/dispatch/affinity/mod.rs b/backend/tests/dispatch/affinity/mod.rs index 985962faf..d1000299b 100644 --- a/backend/tests/dispatch/affinity/mod.rs +++ b/backend/tests/dispatch/affinity/mod.rs @@ -1,17 +1,29 @@ use chrono::{Duration, Utc}; -use codex_proxy_rs::dispatch::affinity::{RedisSessionAffinityStore, SessionAffinityEntry}; +use codex_proxy_rs::dispatch::affinity::{ + RedisSessionAffinityStore, ResponseReplaySnapshot, SessionAffinityEntry, MAX_REPLAY_DEPTH, + MAX_REPLAY_SESSION_BYTES, +}; +use redis::AsyncCommands; +use serde_json::json; use crate::support::storage::create_test_redis; #[tokio::test] async fn redis_affinity_store_upserts_reads_and_forgets_entries() { - let store = RedisSessionAffinityStore::new(create_test_redis("affinity-basic").await); + let redis = create_test_redis("affinity-basic").await; + let store = RedisSessionAffinityStore::new(redis.clone()); let now = Utc::now(); let entry = affinity("acct_1", "conv_1", Some("variant_1"), now); store .upsert("resp_1", &entry, Duration::hours(4)) .await .unwrap(); + let mut connection = redis.manager(); + let ttl_seconds: i64 = connection + .ttl(redis.key("affinity:v2:resp:resp_1")) + .await + .unwrap(); + assert!((3 * 60 * 60..=4 * 60 * 60).contains(&ttl_seconds)); let loaded = store .get("resp_1", now + Duration::minutes(1), Duration::hours(4)) @@ -106,6 +118,160 @@ async fn redis_affinity_store_forgets_all_entries_for_account() { } } +#[tokio::test] +async fn redis_affinity_store_should_keep_replay_growth_linear_across_turns() { + let redis = create_test_redis("affinity-replay-linear").await; + let store = RedisSessionAffinityStore::new(redis.clone()); + let now = Utc::now(); + let mut parent_response_id = None; + let mut total_bytes = 0; + for turn in 0..16u16 { + let response_id = format!("resp_{turn}"); + let turn_input = vec![json!({"role": "user", "content": format!("input-{turn}")})]; + let turn_output = vec![json!({"type": "message", "content": format!("output-{turn}")})]; + let node_bytes = replay_node_bytes(&turn_input, &turn_output); + total_bytes += node_bytes; + let mut entry = affinity( + "acct_linear", + "conv_linear", + None, + now + Duration::milliseconds(i64::from(turn)), + ); + entry.replay = Some(ResponseReplaySnapshot { + parent_response_id: parent_response_id.clone(), + turn_input, + turn_output, + depth: turn + 1, + total_bytes, + }); + store + .upsert(&response_id, &entry, Duration::hours(4)) + .await + .unwrap(); + parent_response_id = Some(response_id); + } + + let head = store + .get("resp_15", now, Duration::hours(4)) + .await + .unwrap() + .unwrap(); + let replay = store + .replay_input( + "resp_15", + &head, + now, + Duration::hours(4), + MAX_REPLAY_DEPTH, + MAX_REPLAY_SESSION_BYTES, + ) + .await + .unwrap() + .unwrap(); + assert_eq!(replay.len(), 32); + assert_eq!(replay[0]["content"], "input-0"); + assert_eq!(replay[31]["content"], "output-15"); + + let mut connection = redis.manager(); + let head_json: String = connection + .get(redis.key("affinity:v2:resp:resp_15")) + .await + .unwrap(); + assert!(head_json.contains("input-15")); + assert!(!head_json.contains("input-0")); + assert!(head_json.len() < 1_024); +} + +#[tokio::test] +async fn redis_affinity_store_should_reject_replay_metadata_over_capacity() { + let store = RedisSessionAffinityStore::new(create_test_redis("affinity-replay-limit").await); + let now = Utc::now(); + let mut entry = affinity("acct_limit", "conv_limit", None, now); + entry.replay = Some(ResponseReplaySnapshot { + parent_response_id: None, + turn_input: vec![json!({"role": "user", "content": "small"})], + turn_output: Vec::new(), + depth: 1, + total_bytes: MAX_REPLAY_SESSION_BYTES + 1, + }); + store + .upsert("resp_limit", &entry, Duration::hours(4)) + .await + .unwrap(); + + assert!(store + .replay_input( + "resp_limit", + &entry, + now, + Duration::hours(4), + MAX_REPLAY_DEPTH, + MAX_REPLAY_SESSION_BYTES, + ) + .await + .unwrap() + .is_none()); +} + +#[tokio::test] +async fn redis_affinity_store_should_use_pruned_account_zset_index() { + let redis = create_test_redis("affinity-account-zset").await; + let store = RedisSessionAffinityStore::new(redis.clone()); + let now = Utc::now(); + let account_key = redis.key("affinity:v2:account:acct_zset"); + let mut connection = redis.manager(); + let _: usize = connection + .zadd( + &account_key, + "resp_expired", + (now - Duration::hours(2)).timestamp_millis(), + ) + .await + .unwrap(); + + store + .upsert( + "resp_current", + &affinity("acct_zset", "conv_zset", None, now), + Duration::hours(1), + ) + .await + .unwrap(); + + let key_type: String = redis::cmd("TYPE") + .arg(&account_key) + .query_async(&mut connection) + .await + .unwrap(); + let members: Vec = connection.zrange(&account_key, 0, -1).await.unwrap(); + assert_eq!(key_type, "zset"); + assert_eq!(members, vec!["resp_current"]); +} + +#[tokio::test] +async fn redis_affinity_store_should_not_persist_already_expired_entries() { + let store = RedisSessionAffinityStore::new(create_test_redis("affinity-expired-write").await); + let now = Utc::now(); + store + .upsert( + "resp_expired", + &affinity( + "acct_expired", + "conv_expired", + None, + now - Duration::hours(2), + ), + Duration::hours(1), + ) + .await + .unwrap(); + assert!(store + .get("resp_expired", now, Duration::hours(1)) + .await + .unwrap() + .is_none()); +} + fn affinity( account_id: &str, conversation_id: &str, @@ -125,3 +291,7 @@ fn affinity( created_at, } } + +fn replay_node_bytes(input: &[serde_json::Value], output: &[serde_json::Value]) -> u64 { + serde_json::to_vec(&(input, output)).unwrap().len() as u64 +} diff --git a/backend/tests/dispatch/service.rs b/backend/tests/dispatch/service.rs index 09304be49..350ac5272 100644 --- a/backend/tests/dispatch/service.rs +++ b/backend/tests/dispatch/service.rs @@ -64,6 +64,19 @@ fn websocket_accept_config() -> WebSocketConfig { config } +fn replay_snapshot(turn_input: Vec, turn_output: Vec) -> ResponseReplaySnapshot { + let total_bytes = serde_json::to_vec(&(&turn_input, &turn_output)) + .unwrap() + .len() as u64; + ResponseReplaySnapshot { + parent_response_id: None, + turn_input, + turn_output, + depth: 1, + total_bytes, + } +} + async fn accept_async(stream: TcpStream) -> Result, WsError> { accept_hdr_async(stream, |_request, _response| {}).await } @@ -276,6 +289,32 @@ async fn test_app_with_account( (router::router().with_state(state), api_key, dir) } +async fn test_app_with_account_and_state( + base_url: String, +) -> ( + axum::Router, + AppState, + String, + crate::support::storage::TestDatabaseGuard, +) { + let (pool, dir) = init_test_db("openai-chat-upstream-state").await; + let api_key = insert_client_api_key(&pool).await; + insert_account(&pool).await; + let state = test_app_state_with_pool(&test_config(test_database_url(), base_url), pool).await; + state + .services + .account_pool + .restore_from_store() + .await + .expect("account pool should restore"); + ( + router::router().with_state(state.clone()), + state, + api_key, + dir, + ) +} + async fn test_app_with_account_and_pool( base_url: String, ) -> ( @@ -455,12 +494,13 @@ async fn test_app_with_two_accounts_and_affinity( function_call_ids: Vec::new(), variant_hash: None, continuation_scope: codex_proxy_rs::upstream::openai::protocol::responses::PreviousResponseScope::Persisted, - replay: Some(ResponseReplaySnapshot { - full_input: vec![json!({ + replay: Some(replay_snapshot( + vec![json!({ "role": "user", "content": "Original managed history" })], - }), + Vec::new(), + )), created_at: now, }, ) @@ -521,12 +561,13 @@ async fn test_app_with_two_accounts_and_affinity_status( function_call_ids: Vec::new(), variant_hash: None, continuation_scope: codex_proxy_rs::upstream::openai::protocol::responses::PreviousResponseScope::Persisted, - replay: Some(ResponseReplaySnapshot { - full_input: vec![json!({ + replay: Some(replay_snapshot( + vec![json!({ "role": "user", "content": "Original affinity history" })], - }), + Vec::new(), + )), created_at: now, }, ) diff --git a/backend/tests/dispatch/service/responses_recovery.rs b/backend/tests/dispatch/service/responses_recovery.rs index 3805ae696..b296e91c0 100644 --- a/backend/tests/dispatch/service/responses_recovery.rs +++ b/backend/tests/dispatch/service/responses_recovery.rs @@ -1148,7 +1148,7 @@ async fn responses_external_previous_response_not_found_should_return_exact_upst .expect(1) .mount(&server) .await; - let (app, api_key, _pool, _dir) = test_app_with_two_accounts(server.uri()).await; + let (app, api_key, pool, _dir) = test_app_with_two_accounts(server.uri()).await; let response = app .oneshot( @@ -1157,12 +1157,13 @@ async fn responses_external_previous_response_not_found_should_return_exact_upst .uri("/v1/responses") .header("authorization", format!("Bearer {api_key}")) .header("content-type", "application/json") + .header("x-request-id", "req_external_previous_not_found") .body(Body::from( json!({ "model": "gpt-5.5", "previous_response_id": "resp_external", "input": [{"role": "user", "content": "Continue"}], - "stream": false, + "stream": true, "use_websocket": false }) .to_string(), @@ -1173,10 +1174,21 @@ async fn responses_external_previous_response_not_found_should_return_exact_upst .unwrap(); let status = response.status(); let body = response_json(response).await; + let event = latest_response_upstream_ops_error_log(&pool).await; + let metadata: Value = serde_json::from_str(&event.metadata_json).unwrap(); assert_eq!(status, StatusCode::BAD_REQUEST); assert_eq!(body, upstream_error); assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!( + event.request_id.as_deref(), + Some("req_external_previous_not_found") + ); + assert_eq!(event.status_code, Some(400)); + assert_eq!(event.client_status_code, Some(400)); + assert_eq!(event.upstream_status_code, Some(400)); + assert_eq!(event.attempt_index, Some(0)); + assert_eq!(metadata["attemptCount"], 1); } #[tokio::test] @@ -1218,8 +1230,8 @@ async fn responses_managed_history_should_rebuild_full_replay_for_next_configure function_call_ids: Vec::new(), variant_hash: None, continuation_scope: codex_proxy_rs::upstream::openai::protocol::responses::PreviousResponseScope::Persisted, - replay: Some(ResponseReplaySnapshot { - full_input: vec![ + replay: Some(replay_snapshot( + vec![ json!({"role": "user", "content": "Earlier question"}), json!({ "type": "reasoning", @@ -1228,7 +1240,8 @@ async fn responses_managed_history_should_rebuild_full_replay_for_next_configure "summary": [] }), ], - }), + Vec::new(), + )), created_at: Utc::now(), }, ) @@ -1273,7 +1286,7 @@ async fn responses_managed_history_should_rebuild_full_replay_for_next_configure let body: Value = serde_json::from_slice(&secondary.body).unwrap(); assert!(body.get("previous_response_id").is_none()); assert_eq!(body["input"][0]["content"], "Earlier question"); - assert_eq!(body["input"][1]["id"], "reasoning-old"); + assert!(body["input"][1].get("id").is_none()); assert!(body["input"][1].get("encrypted_content").is_none()); assert_eq!(body["input"][2]["content"], "Current question"); assert!(secondary.headers.get("x-codex-turn-state").is_none()); diff --git a/backend/tests/dispatch/service/responses_websocket.rs b/backend/tests/dispatch/service/responses_websocket.rs index c3229268b..b29639303 100644 --- a/backend/tests/dispatch/service/responses_websocket.rs +++ b/backend/tests/dispatch/service/responses_websocket.rs @@ -431,6 +431,222 @@ async fn responses_websocket_should_replay_history_when_reused_connection_dies_b assert!(fresh_payload.get("prompt_cache_key").is_none()); } +#[tokio::test] +async fn responses_websocket_stream_should_replay_managed_history_after_previous_response_not_found( +) { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (first_stream, _) = listener.accept().await.unwrap(); + let mut first_websocket = accept_async(first_stream).await.unwrap(); + let first_message = first_websocket.next().await.unwrap().unwrap(); + let first_payload = serde_json::from_str::(&first_message.into_text().unwrap()) + .expect("first websocket payload should be json"); + first_websocket + .send(Message::Text( + response_failed_websocket_message( + "resp_previous_missing", + "previous_response_not_found", + "Previous response with id resp_managed_stale was not found", + ) + .into(), + )) + .await + .unwrap(); + first_websocket.close(None).await.unwrap(); + + let replay_payload = + accept_successful_websocket_response(&listener, "resp_replayed_success").await; + (first_payload, replay_payload) + }); + let (app, state, api_key, _dir) = test_app_with_account_and_state(base_url).await; + state + .services + .session_affinity + .record( + "resp_managed_stale".to_string(), + SessionAffinityEntry { + account_id: "acct_chat".to_string(), + conversation_id: "conversation-managed-stale".to_string(), + turn_state: Some("account-bound-turn-state".to_string()), + instructions_hash: None, + input_tokens: Some(7), + function_call_ids: Vec::new(), + variant_hash: None, + continuation_scope: codex_proxy_rs::upstream::openai::protocol::responses::PreviousResponseScope::Persisted, + replay: Some(replay_snapshot( + vec![json!({ + "role": "user", + "content": "Earlier managed question" + })], + Vec::new(), + )), + created_at: Utc::now(), + }, + ) + .await + .unwrap(); + + let response = app + .oneshot(responses_json_request( + &api_key, + &json!({ + "model": "gpt-5.5", + "previous_response_id": "resp_managed_stale", + "input": [{"role": "user", "content": "Current managed question"}], + "stream": true + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = response_text(response).await; + let (first_payload, replay_payload) = upstream.await.unwrap(); + + assert!(body.contains("resp_replayed_success"), "{body}"); + assert!(!body.contains("resp_previous_missing"), "{body}"); + assert_eq!(first_payload["previous_response_id"], "resp_managed_stale"); + assert!(replay_payload.get("previous_response_id").is_none()); + assert_eq!( + replay_payload["input"][0]["content"], + "Earlier managed question" + ); + assert_eq!( + replay_payload["input"][1]["content"], + "Current managed question" + ); +} + +#[tokio::test] +async fn responses_websocket_should_store_sanitized_incremental_replay_snapshot() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let upstream = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut websocket = accept_async(stream).await.unwrap(); + let _payload = websocket.next().await.unwrap().unwrap(); + let mut completed: Value = serde_json::from_str(include_str!( + "../../fixtures/responses/websocket/completed_with_reasoning_replay.json" + )) + .unwrap(); + completed["response"]["status"] = Value::String("completed".to_string()); + completed["response"]["output"] + .as_array_mut() + .unwrap() + .push(json!({ + "type": "message", + "id": "msg_sensitive_output", + "role": "assistant", + "content": [{"type": "output_text", "text": "done"}] + })); + websocket + .send(Message::Text(completed.to_string().into())) + .await + .unwrap(); + websocket.close(None).await.unwrap(); + }); + let (app, state, api_key, _dir) = test_app_with_account_and_state(base_url).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + &json!({ + "model": "gpt-5.5", + "stream": false, + "input": [ + { + "type": "message", + "id": "account-bound-input-id", + "role": "user", + "content": [{ + "type": "input_text", + "text": "remember this", + "encrypted_content": "nested-secret" + }] + }, + { + "type": "function_call_output", + "call_id": "call_must_survive", + "output": "ok" + } + ] + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + upstream.await.unwrap(); + + let stored = state + .services + .session_affinity + .lookup("resp_implicit_resume_first", Utc::now()) + .await + .unwrap(); + let replay = stored.replay.unwrap(); + assert_eq!(replay.depth, 1); + assert!(replay.parent_response_id.is_none()); + assert!(replay.turn_input[0].get("id").is_none()); + assert_eq!(replay.turn_input[1]["call_id"], "call_must_survive"); + assert!(replay.turn_output[0].get("id").is_none()); + let serialized = serde_json::to_string(&replay).unwrap(); + assert!(!serialized.contains("encrypted_content")); + assert!(!serialized.contains("nested-secret")); + assert!(!serialized.contains("enc_reasoning_replay")); +} + +#[tokio::test] +async fn responses_should_skip_oversized_replay_snapshot_without_failing_response() { + let server = MockServer::start().await; + let oversized = "x".repeat(2 * 1024 * 1024 + 1024); + let completed = json!({ + "response": { + "id": "resp_oversized_replay", + "object": "response", + "status": "completed", + "output": [{ + "type": "message", + "id": "msg_oversized", + "role": "assistant", + "content": [{"type": "output_text", "text": oversized}] + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2} + } + }); + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(format!("event: response.completed\ndata: {completed}\n\n")), + ) + .expect(1) + .mount(&server) + .await; + let (app, state, api_key, _dir) = test_app_with_account_and_state(server.uri()).await; + + let response = app + .oneshot(responses_json_request( + &api_key, + &json!({ + "model": "gpt-5.5", + "input": [{"role": "user", "content": "small input"}], + "stream": false, + "use_websocket": false + }), + )) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let stored = state + .services + .session_affinity + .lookup("resp_oversized_replay", Utc::now()) + .await + .unwrap(); + assert!(stored.replay.is_none()); +} + #[tokio::test] async fn responses_websocket_should_not_reuse_connection_when_pool_is_disabled() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/backend/tests/dispatch/service/usage_logging.rs b/backend/tests/dispatch/service/usage_logging.rs index 53e271aa5..e9c60d9ab 100644 --- a/backend/tests/dispatch/service/usage_logging.rs +++ b/backend/tests/dispatch/service/usage_logging.rs @@ -1,5 +1,110 @@ use super::*; +#[tokio::test] +async fn responses_should_succeed_when_postgres_telemetry_write_fails() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + let (app, api_key, pool, _dir) = test_app_with_account_pool_and_telemetry(server.uri()).await; + sqlx::query("alter table usage_records rename to usage_records_unavailable") + .execute(&pool) + .await + .unwrap(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_json(response).await["id"], "resp_usage"); +} + +#[tokio::test] +async fn responses_should_succeed_when_redis_affinity_write_fails() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/codex/responses")) + .respond_with( + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_string(RESPONSES_COMPLETED_USAGE_SSE), + ) + .expect(1) + .mount(&server) + .await; + let (pool, _dir) = init_test_db("openai-affinity-write-failure").await; + let api_key = insert_client_api_key(&pool).await; + insert_account(&pool).await; + let redis = create_test_redis("affinity-write-failure").await; + let config = test_config(test_database_url(), server.uri()); + let usage_record_options = UsageRecordOptions::from_config(&config); + let state = + test_app_state_with_storage(config, pool, redis.clone(), usage_record_options).await; + state + .services + .account_pool + .restore_from_store() + .await + .unwrap(); + let mut connection = redis.manager(); + let _: () = redis::cmd("SET") + .arg(redis.key("affinity:v2:account:acct_chat")) + .arg("wrong-type") + .query_async(&mut connection) + .await + .unwrap(); + let app = router::router().with_state(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/v1/responses") + .header("authorization", format!("Bearer {api_key}")) + .header("content-type", "application/json") + .body(Body::from( + json!({ + "model": "gpt-5.5", + "input": [], + "stream": false, + "use_websocket": false + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response_json(response).await["id"], "resp_usage"); +} + #[tokio::test] async fn responses_should_use_imported_account_record_usage_cookie_and_usage_record() { let server = MockServer::start().await; @@ -57,15 +162,6 @@ async fn responses_should_use_imported_account_record_usage_cookie_and_usage_rec .await .unwrap(); assert_eq!(usage, (1, 7, 4, 2)); - let model_usage: (i64, i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens, cached_tokens from account_model_usage where account_id = $1 and model = $2", - ) - .bind("acct_chat") - .bind("gpt-5.5") - .fetch_one(&pool) - .await - .unwrap(); - assert_eq!(model_usage, (1, 7, 4, 2)); let cookie_header = PgCookieStore::new(pool.clone()) .cookie_header("acct_chat", "chatgpt.com") .await @@ -498,14 +594,6 @@ async fn responses_stream_should_proxy_sse_and_record_usage() { .fetch_one(&pool) .await .unwrap(); - let model_usage: (i64, i64, i64) = sqlx::query_as( - "select request_count, input_tokens, output_tokens from account_model_usage where account_id = $1 and model = $2", - ) - .bind("acct_chat") - .bind("gpt-5.5") - .fetch_one(&pool) - .await - .unwrap(); assert_eq!(status, StatusCode::OK); assert!(content_type.starts_with("text/event-stream")); @@ -516,7 +604,6 @@ async fn responses_stream_should_proxy_sse_and_record_usage() { "stream responses should terminate clients, body was {body:?}" ); assert_eq!(usage, (1, 3, 5)); - assert_eq!(model_usage, (1, 3, 5)); } #[tokio::test] diff --git a/backend/tests/fleet/pool/runtime.rs b/backend/tests/fleet/pool/runtime.rs index ececf3396..59680e6b8 100644 --- a/backend/tests/fleet/pool/runtime.rs +++ b/backend/tests/fleet/pool/runtime.rs @@ -118,6 +118,95 @@ async fn runtime_account_lease_drop_should_release_capacity() { .expect("dropped lease should release its slot"); } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn runtime_account_pool_should_preserve_slot_invariants_under_contention() { + let (pool, _dir) = init_test_db("runtime-account-pool-contention").await; + insert_account(&pool, "acct_contention").await; + let service = Arc::new(AccountPoolService::new( + Arc::new(PgAccountStore::new(pool.clone())) as Arc, + Arc::new(PgAccountUsageStore::new(pool)), + AccountPoolOptions { + max_concurrent_per_account: 4, + ..AccountPoolOptions::default() + }, + 0, + )); + service.restore_from_store().await.unwrap(); + + let acquisitions = futures::future::join_all((0..64).map(|_| { + let service = Arc::clone(&service); + async move { + service + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", Utc::now())) + .await + } + })) + .await; + let leases = acquisitions.into_iter().flatten().collect::>(); + let held = service.capacity_summary_now().await; + assert_eq!(leases.len(), 4); + assert_eq!(held.used_slots, 4); + assert_eq!(held.available_slots, 0); + + futures::future::join_all( + leases + .into_iter() + .map(|lease| lease.release_without_usage()), + ) + .await; + let released = service.capacity_summary_now().await; + assert_eq!(released.used_slots, 0); + assert_eq!(released.available_slots, 4); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn runtime_account_pool_should_release_slots_when_tasks_are_cancelled() { + let (pool, _dir) = init_test_db("runtime-account-pool-cancel").await; + insert_account(&pool, "acct_cancel").await; + let service = Arc::new(AccountPoolService::new( + Arc::new(PgAccountStore::new(pool.clone())) as Arc, + Arc::new(PgAccountUsageStore::new(pool)), + AccountPoolOptions { + max_concurrent_per_account: 8, + ..AccountPoolOptions::default() + }, + 0, + )); + service.restore_from_store().await.unwrap(); + + let ready = Arc::new(tokio::sync::Barrier::new(9)); + let mut tasks = Vec::new(); + for _ in 0..8 { + let service = Arc::clone(&service); + let ready = Arc::clone(&ready); + tasks.push(tokio::spawn(async move { + let _lease = service + .acquire_with(&AccountAcquireRequest::new("gpt-5.5", Utc::now())) + .await + .unwrap(); + ready.wait().await; + std::future::pending::<()>().await; + })); + } + ready.wait().await; + assert_eq!(service.capacity_summary_now().await.used_slots, 8); + for task in tasks { + task.abort(); + let _ = task.await; + } + + tokio::time::timeout(std::time::Duration::from_secs(1), async { + loop { + if service.capacity_summary_now().await.used_slots == 0 { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("cancelled account leases should release all slots"); +} + async fn insert_account(pool: &sqlx::PgPool, id: &str) { sqlx::query( "insert into accounts (id, email, chatgpt_account_id, chatgpt_user_id, access_token, access_token_expires_at, status, added_at, updated_at) values ($1, $2, $3, $4, $5, $6, $7, $8, $9)", diff --git a/backend/tests/fleet/pool/selection.rs b/backend/tests/fleet/pool/selection.rs index 5794d6747..594c68cd0 100644 --- a/backend/tests/fleet/pool/selection.rs +++ b/backend/tests/fleet/pool/selection.rs @@ -458,12 +458,7 @@ fn account_pool_distinct_plan_accounts_should_filter_like_model_refresh() { assert_eq!(selected.len(), 1); assert_eq!(selected[0].plan_type, "plus"); assert_eq!(selected[0].account.id, "plus-ok"); - assert_eq!( - pool.release_distinct_plan_account(&selected[0]) - .unwrap() - .model, - None - ); + assert!(pool.release_distinct_plan_account(&selected[0])); } #[test] diff --git a/backend/tests/fleet/pool/usage_window.rs b/backend/tests/fleet/pool/usage_window.rs index e5f1f6bb5..954e7d16d 100644 --- a/backend/tests/fleet/pool/usage_window.rs +++ b/backend/tests/fleet/pool/usage_window.rs @@ -113,14 +113,8 @@ fn release_should_remove_the_exact_concurrent_slot() { )) .unwrap(); - assert_eq!( - pool.release(&second).unwrap().model.as_deref(), - Some("model-second") - ); - assert_eq!( - pool.release(&first).unwrap().model.as_deref(), - Some("model-first") - ); + assert!(pool.release(&second)); + assert!(pool.release(&first)); } #[test] diff --git a/backend/tests/fleet/store/mod.rs b/backend/tests/fleet/store/mod.rs index e9572c56f..a6eca05aa 100644 --- a/backend/tests/fleet/store/mod.rs +++ b/backend/tests/fleet/store/mod.rs @@ -2,7 +2,7 @@ use chrono::{DateTime, Duration, Utc}; use codex_proxy_rs::fleet::{ account::AccountStatus, quota::{quota_snapshot_limit_reached, quota_snapshot_reset_at}, - store::{AccountClaimsUpdate, AccountStore, NewAccount, PgAccountStore}, + store::{AccountClaimsUpdate, AccountStore, ImportedAccountUpsert, NewAccount, PgAccountStore}, }; use codex_proxy_rs::telemetry::account_usage::store::{ AccountUsageDelta, AccountUsageStore, AccountUsageWindow, PgAccountUsageStore, @@ -12,6 +12,15 @@ use serde_json::json; use crate::support::storage::init_test_db; +#[derive(sqlx::FromRow)] +struct ImportedAccountState { + access_token: String, + refresh_token: Option, + next_refresh_at: Option>, + quota_verify_required: bool, + quota_fetched_at: Option>, +} + #[tokio::test] async fn account_store_should_store_plain_tokens_and_load_secret_wrappers() { let (pool, _dir) = account_store_parts("accounts", 4).await; @@ -53,6 +62,78 @@ async fn account_store_should_store_plain_tokens_and_load_secret_wrappers() { assert_eq!(loaded.status, AccountStatus::Active); } +#[tokio::test] +async fn account_store_import_upsert_should_commit_complete_state_atomically() { + let (pool, _dir) = account_store_parts("accounts-import-atomic", 1).await; + let repo = PgAccountStore::new(pool.clone()); + let next_refresh_at = Utc::now() + Duration::minutes(30); + let quota_fetched_at = Utc::now(); + repo.upsert_from_import(ImportedAccountUpsert { + account: imported_account("acct_import", Some("refresh-import")), + next_refresh_at: Some(next_refresh_at), + quota_json: Some(r#"{"plan_type":"plus","remaining":42}"#.to_string()), + quota_fetched_at: Some(quota_fetched_at), + quota_verify_required: true, + }) + .await + .unwrap(); + + let stored: ImportedAccountState = sqlx::query_as( + "select access_token, refresh_token, next_refresh_at, quota_verify_required, + quota_fetched_at + from accounts where id = 'acct_import'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored.access_token, "access-import"); + assert_eq!(stored.refresh_token.as_deref(), Some("refresh-import")); + assert_eq!(stored.next_refresh_at, Some(pg_timestamp(next_refresh_at))); + assert!(stored.quota_verify_required); + assert_eq!( + stored.quota_fetched_at, + Some(pg_timestamp(quota_fetched_at)) + ); +} + +#[tokio::test] +async fn account_store_import_upsert_should_leave_no_partial_account_on_failure() { + let (pool, _dir) = account_store_parts("accounts-import-rollback", 1).await; + let repo = PgAccountStore::new(pool.clone()); + let result = repo + .upsert_from_import(ImportedAccountUpsert { + account: imported_account("acct_invalid_import", None), + next_refresh_at: Some(Utc::now()), + quota_json: Some("not-json".to_string()), + quota_fetched_at: Some(Utc::now()), + quota_verify_required: true, + }) + .await; + assert!(result.is_err()); + let rows: i64 = + sqlx::query_scalar("select count(*) from accounts where id = 'acct_invalid_import'") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rows, 0); +} + +fn imported_account(id: &str, refresh_token: Option<&str>) -> NewAccount { + NewAccount { + id: id.to_string(), + email: Some("import@example.com".to_string()), + account_id: Some(format!("chatgpt-{id}")), + user_id: Some(format!("user-{id}")), + label: Some("imported".to_string()), + plan_type: Some("plus".to_string()), + access_token: SecretString::new("access-import".to_string().into()), + refresh_token: refresh_token.map(|token| SecretString::new(token.to_string().into())), + access_token_expires_at: Some(Utc::now() + Duration::hours(1)), + status: AccountStatus::Active, + added_at: None, + } +} + #[tokio::test] async fn account_store_should_page_account_metadata_by_added_at_desc() { let (pool, _dir) = account_store_parts("accounts", 5).await; @@ -83,6 +164,33 @@ async fn account_store_should_page_account_metadata_by_added_at_desc() { ); } +#[tokio::test] +async fn account_store_page_should_return_row_mapping_errors_instead_of_skipping_rows() { + let (pool, _dir) = account_store_parts("accounts-invalid-row", 1).await; + let repo = PgAccountStore::new(pool.clone()); + sqlx::query("alter table accounts drop constraint accounts_status_check") + .execute(&pool) + .await + .unwrap(); + let now = Utc::now(); + sqlx::query( + "insert into accounts (id, access_token, status, added_at, updated_at) + values ('acct_invalid_status', 'token', 'corrupt', $1, $1)", + ) + .bind(now) + .execute(&pool) + .await + .unwrap(); + + let error = repo + .list_metadata_page(1, 20, None) + .await + .expect_err("invalid rows must fail the page instead of disappearing"); + assert!(error + .to_string() + .contains("PostgreSQL account store status error: corrupt")); +} + #[tokio::test] async fn account_store_should_list_metadata_without_exposing_tokens() { let (pool, _dir) = account_store_parts("accounts", 35).await; diff --git a/backend/tests/infra/database.rs b/backend/tests/infra/database.rs index 411c1763b..80ce01575 100644 --- a/backend/tests/infra/database.rs +++ b/backend/tests/infra/database.rs @@ -1,9 +1,11 @@ use std::collections::BTreeSet; +use codex_proxy_rs::infra::database::migrate; + use crate::support::storage::init_test_db; #[tokio::test] -async fn postgres_schema_is_terminal_v1_without_runtime_tables() { +async fn postgres_schema_is_terminal_v1_without_unused_storage() { let (pool, _guard) = init_test_db("schema-terminal-v1").await; let tables = sqlx::query_scalar::<_, String>( "select table_name @@ -19,7 +21,6 @@ async fn postgres_schema_is_terminal_v1_without_runtime_tables() { BTreeSet::from_iter( [ "account_cookies", - "account_model_usage", "account_usage", "accounts", "admin_users", @@ -36,13 +37,28 @@ async fn postgres_schema_is_terminal_v1_without_runtime_tables() { ) ); - let versions = sqlx::query_as::<_, (i64, String)>( - "select version, name from schema_migrations order by version", + let versions = sqlx::query_as::<_, (i64, String, String)>( + "select version, name, checksum from schema_migrations order by version", ) .fetch_all(&pool) .await .unwrap(); - assert_eq!(versions, vec![(1, "initial".to_string())]); + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].0, 1); + assert_eq!(versions[0].1, "initial"); + assert!(versions.iter().all(|migration| migration.2.len() == 64)); +} + +#[tokio::test] +async fn postgres_migrate_should_reject_changed_applied_sql_checksum() { + let (pool, _guard) = init_test_db("schema-checksum-mismatch").await; + sqlx::query("update schema_migrations set checksum = repeat('0', 64) where version = 1") + .execute(&pool) + .await + .unwrap(); + + let error = migrate(&pool).await.unwrap_err(); + assert!(error.to_string().contains("checksum mismatch")); } #[tokio::test] @@ -136,10 +152,16 @@ async fn postgres_schema_has_required_fact_and_bucket_indexes() { "idx_ops_error_logs_created_id", "idx_ops_error_logs_key_created", "idx_ops_error_logs_failure_class", - "idx_request_time_buckets_model", ] { assert!(indexes.contains(index), "missing {index}"); } + for retired_index in [ + "idx_accounts_status", + "idx_account_usage_last_used_account", + "idx_request_time_buckets_model", + ] { + assert!(!indexes.contains(retired_index), "retained {retired_index}"); + } } #[tokio::test] diff --git a/backend/tests/keys/store.rs b/backend/tests/keys/store.rs index b9dc13a78..086ff2078 100644 --- a/backend/tests/keys/store.rs +++ b/backend/tests/keys/store.rs @@ -57,6 +57,34 @@ async fn client_key_service_should_verify_by_unique_key_and_defer_last_used_flus assert!(after_flush.last_used_at.is_some()); } +#[tokio::test] +async fn client_key_service_should_flush_last_used_after_debounce() { + let (store, _dir) = client_key_store("client-keys-runtime-auto-flush").await; + let created = KeyManageService::new(store.clone()) + .create("runtime-auto-flush") + .await + .unwrap(); + let runtime = KeyVerifier::new(store.clone()); + runtime.verify(&created.key).await.unwrap(); + + tokio::task::yield_now().await; + tokio::time::pause(); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + tokio::time::resume(); + for _ in 0..100 { + if store + .get(&created.id) + .await + .unwrap() + .is_some_and(|key| key.last_used_at.is_some()) + { + return; + } + tokio::task::yield_now().await; + } + panic!("last_used_at was not flushed after the debounce interval"); +} + #[tokio::test] async fn client_key_service_should_not_accept_disabled_keys() { let (store, _dir) = client_key_store("client-keys-runtime-disabled").await; diff --git a/backend/tests/models/catalog.rs b/backend/tests/models/catalog.rs index c89879dc3..450809e90 100644 --- a/backend/tests/models/catalog.rs +++ b/backend/tests/models/catalog.rs @@ -115,10 +115,10 @@ async fn model_service_should_return_builtin_catalog_when_store_is_missing() { async fn model_service_catalog_should_use_loaded_memory_snapshot() { let store = Arc::new(InMemorySnapshotStore::default()); store - .replace_plan_snapshot(&ModelPlanSnapshot::from_backend_entries( + .replace_plan_snapshots(&[ModelPlanSnapshot::from_backend_entries( "plus", backend_models("gpt-6"), - )) + )]) .await .unwrap(); let service = ModelService::new(test_model_config(), Some(store.clone()), None); @@ -264,14 +264,17 @@ struct InMemorySnapshotStore { #[async_trait] impl ModelSnapshotStore for InMemorySnapshotStore { - async fn replace_plan_snapshot( + async fn replace_plan_snapshots( &self, - snapshot: &ModelPlanSnapshot, + snapshots: &[ModelPlanSnapshot], ) -> ModelSnapshotStoreResult<()> { - self.snapshots - .lock() - .await - .insert(snapshot.plan_type.clone(), snapshot.clone()); + let mut stored = self.snapshots.lock().await; + stored.clear(); + stored.extend( + snapshots + .iter() + .map(|snapshot| (snapshot.plan_type.clone(), snapshot.clone())), + ); Ok(()) } diff --git a/backend/tests/models/store.rs b/backend/tests/models/store.rs index ca5df5b89..9b2d81062 100644 --- a/backend/tests/models/store.rs +++ b/backend/tests/models/store.rs @@ -18,7 +18,7 @@ async fn model_snapshot_store_should_replace_and_load_plan_snapshots() { }], ); - ModelSnapshotStore::replace_plan_snapshot(&repo, &snapshot) + ModelSnapshotStore::replace_plan_snapshots(&repo, &[snapshot]) .await .unwrap(); let loaded = ModelSnapshotStore::list_plan_snapshots(&repo) @@ -30,3 +30,35 @@ async fn model_snapshot_store_should_replace_and_load_plan_snapshots() { assert_eq!(loaded[0].models[0].id, "gpt-team"); assert_eq!(loaded[0].models[0].source, "backend"); } + +#[tokio::test] +async fn model_snapshot_store_should_remove_plan_fields_missing_from_refresh() { + let redis = create_test_redis("model-snapshots-remove-stale").await; + let repo = RedisModelSnapshotStore::new(redis); + let plus = ModelPlanSnapshot::from_backend_entries( + "plus", + vec![BackendModelEntry { + id: Some("gpt-plus".to_string()), + ..BackendModelEntry::default() + }], + ); + let team = ModelPlanSnapshot::from_backend_entries( + "team", + vec![BackendModelEntry { + id: Some("gpt-team".to_string()), + ..BackendModelEntry::default() + }], + ); + ModelSnapshotStore::replace_plan_snapshots(&repo, &[plus.clone(), team]) + .await + .unwrap(); + + ModelSnapshotStore::replace_plan_snapshots(&repo, &[plus]) + .await + .unwrap(); + let loaded = ModelSnapshotStore::list_plan_snapshots(&repo) + .await + .unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].plan_type, "plus"); +} diff --git a/backend/tests/support/storage.rs b/backend/tests/support/storage.rs index 415607c7d..82378f720 100644 --- a/backend/tests/support/storage.rs +++ b/backend/tests/support/storage.rs @@ -1,4 +1,4 @@ -use std::{env, thread}; +use std::{env, sync::OnceLock, thread}; use chrono::{DateTime, Utc}; use codex_proxy_rs::{ @@ -22,6 +22,8 @@ const DEFAULT_TEST_DATABASE_URL: &str = "postgres://codex_proxy:codex_proxy@127.0.0.1:5432/codex_proxy"; const DEFAULT_TEST_REDIS_URL: &str = "redis://127.0.0.1:6379"; static DATABASE_INIT_LIMIT: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2); +static REDIS_TEST_CLEANUP: tokio::sync::OnceCell<()> = tokio::sync::OnceCell::const_new(); +static REDIS_TEST_RUN_ID: OnceLock = OnceLock::new(); pub(crate) struct TestDatabaseGuard { database_name: Option, @@ -122,13 +124,48 @@ async fn drop_test_database(base_url: &str, database_name: &str) -> Result<(), s } pub(crate) async fn create_test_redis(label: &str) -> RedisConnection { + let redis_url = test_redis_url(); + REDIS_TEST_CLEANUP + .get_or_init(|| async { + cleanup_stale_test_redis_keys(&redis_url) + .await + .unwrap_or_else(|error| panic!("clean stale Redis test keys: {error}")); + }) + .await; let label = sanitize_label(label); - let prefix = format!("cpr:test:{label}:{}", Uuid::new_v4().simple()); - RedisConnection::connect(&test_redis_url(), prefix) + let run_id = REDIS_TEST_RUN_ID.get_or_init(|| Uuid::new_v4().simple().to_string()); + let prefix = format!("cpr:test:{run_id}:{label}:{}", Uuid::new_v4().simple()); + RedisConnection::connect(&redis_url, prefix) .await .unwrap_or_else(|error| panic!("connect CPR_TEST_REDIS_URL: {error}")) } +async fn cleanup_stale_test_redis_keys(redis_url: &str) -> redis::RedisResult<()> { + let client = redis::Client::open(redis_url)?; + let mut connection = client.get_connection_manager().await?; + let mut cursor = 0u64; + loop { + let (next_cursor, keys): (u64, Vec) = redis::cmd("SCAN") + .arg(cursor) + .arg("MATCH") + .arg("cpr:test:*") + .arg("COUNT") + .arg(500) + .query_async(&mut connection) + .await?; + if !keys.is_empty() { + let _: usize = redis::cmd("UNLINK") + .arg(keys) + .query_async(&mut connection) + .await?; + } + if next_cursor == 0 { + return Ok(()); + } + cursor = next_cursor; + } +} + pub(crate) fn background_task_stores(pool: PgPool, redis: RedisConnection) -> BackgroundTaskStores { BackgroundTaskStores { redis: redis.clone(), diff --git a/backend/tests/telemetry/account_usage/store.rs b/backend/tests/telemetry/account_usage/store.rs index 104668e0c..03ffa9d7a 100644 --- a/backend/tests/telemetry/account_usage/store.rs +++ b/backend/tests/telemetry/account_usage/store.rs @@ -1,49 +1,53 @@ -use std::num::NonZeroU32; - -use chrono::Utc; -use codex_proxy_rs::telemetry::account_usage::store::PgAccountUsageStore; +use chrono::{Duration, Utc}; +use codex_proxy_rs::telemetry::account_usage::store::{ + AccountUsageStore, AccountUsageWindow, PgAccountUsageStore, +}; use crate::support::storage::init_test_db; #[tokio::test] -async fn trim_model_usage_should_keep_latest_rows_per_account() { - let (pool, _guard) = init_test_db("account-model-usage-limit").await; +async fn rate_limit_window_update_should_reset_counters_atomically() { + let (pool, _guard) = init_test_db("account-rate-limit-window").await; sqlx::query( "insert into accounts (id, access_token, status, added_at, updated_at) - values ('account-limit', 'token', 'active', $1, $1)", + values ('account-window', 'token', 'active', now(), now())", ) - .bind(Utc::now()) .execute(&pool) .await - .expect("insert account"); - sqlx::query( - "insert into account_model_usage (account_id, model, request_count, last_used_at) - select - 'account-limit', - 'model-' || lpad(sequence::text, 3, '0'), - 1, - $1 + sequence * interval '1 second' - from generate_series(0, 101) as sequence", - ) - .bind(Utc::now()) - .execute(&pool) - .await - .expect("insert model usage rows"); - - let deleted = PgAccountUsageStore::new(pool.clone()) - .trim_model_usage_to_limit(NonZeroU32::new(100).unwrap()) + .unwrap(); + let store = PgAccountUsageStore::new(pool.clone()); + let initial_reset_at = Utc::now() + Duration::hours(1); + store + .sync_runtime_window( + "account-window", + AccountUsageWindow { + request_count: 7, + input_tokens: 11, + output_tokens: 13, + started_at: Some(Utc::now()), + reset_at: Some(initial_reset_at), + limit_window_seconds: Some(3_600), + ..AccountUsageWindow::default() + }, + ) .await - .expect("trim model usage rows"); - let (remaining, oldest_remaining): (i64, i64) = sqlx::query_as( - "select - count(*), - count(*) filter (where model in ('model-000', 'model-001')) - from account_model_usage - where account_id = 'account-limit'", + .unwrap(); + + store + .sync_rate_limit_window( + "account-window", + initial_reset_at + Duration::hours(1), + Some(3_600), + ) + .await + .unwrap(); + + let counters: (i64, i64, i64) = sqlx::query_as( + "select window_request_count, window_input_tokens, window_output_tokens + from account_usage where account_id = 'account-window'", ) .fetch_one(&pool) .await - .expect("count retained model usage rows"); - - assert_eq!((deleted, remaining, oldest_remaining), (2, 100, 0)); + .unwrap(); + assert_eq!(counters, (0, 0, 0)); } diff --git a/backend/tests/telemetry/ops/store.rs b/backend/tests/telemetry/ops/store.rs index d00a4cd9a..cd42b4e69 100644 --- a/backend/tests/telemetry/ops/store.rs +++ b/backend/tests/telemetry/ops/store.rs @@ -1,3 +1,4 @@ +use chrono::{Duration, Utc}; use codex_proxy_rs::telemetry::{ ops::store::{OpsErrorFilter, PgOpsErrorLogStore}, ops::types::OpsErrorLog, @@ -36,6 +37,199 @@ async fn ops_store_filters_diagnostic_dimensions() { assert_eq!(page.items[0].id, "ops_error_matching"); } +#[tokio::test] +async fn ops_store_should_apply_every_query_filter_dimension() { + let (pool, _guard) = init_test_db("ops-error-filter-matrix").await; + let store = PgOpsErrorLogStore::new(pool); + let now = Utc::now(); + let mut matching = OpsErrorLog::new("response", "needle failure"); + matching.id = "ops_matrix_matching".to_string(); + matching.client_api_key_id = Some("key_matching".to_string()); + matching.provider = Some("provider_matching".to_string()); + matching.request_id = Some("request_matching".to_string()); + matching.account_id = Some("account_matching".to_string()); + matching.route = Some("/v1/matching".to_string()); + matching.model = Some("model_matching".to_string()); + matching.status_code = Some(503); + matching.client_status_code = Some(502); + matching.upstream_status_code = Some(503); + matching.transport = Some("websocket".to_string()); + matching.attempt_index = Some(3); + matching.failure_class = Some("matching_failure".to_string()); + matching.response_id = Some("response_matching".to_string()); + matching.upstream_request_id = Some("upstream_matching".to_string()); + matching.created_at = now; + store.append(&matching).await.unwrap(); + + let mut other = OpsErrorLog::new("other", "other failure"); + other.id = "ops_matrix_other".to_string(); + other.client_api_key_id = Some("key_other".to_string()); + other.provider = Some("provider_other".to_string()); + other.request_id = Some("request_other".to_string()); + other.account_id = Some("account_other".to_string()); + other.route = Some("/v1/other".to_string()); + other.model = Some("model_other".to_string()); + other.status_code = Some(500); + other.client_status_code = Some(500); + other.upstream_status_code = Some(500); + other.transport = Some("http_sse".to_string()); + other.attempt_index = Some(4); + other.failure_class = Some("other_failure".to_string()); + other.response_id = Some("response_other".to_string()); + other.upstream_request_id = Some("upstream_other".to_string()); + other.created_at = now - Duration::hours(2); + store.append(&other).await.unwrap(); + + let filters = vec![ + ( + "kind", + OpsErrorFilter { + kind: Some(matching.kind.clone()), + ..Default::default() + }, + ), + ( + "client key", + OpsErrorFilter { + client_api_key_id: matching.client_api_key_id.clone(), + ..Default::default() + }, + ), + ( + "provider", + OpsErrorFilter { + provider: matching.provider.clone(), + ..Default::default() + }, + ), + ( + "request", + OpsErrorFilter { + request_id: matching.request_id.clone(), + ..Default::default() + }, + ), + ( + "account", + OpsErrorFilter { + account_id: matching.account_id.clone(), + ..Default::default() + }, + ), + ( + "route", + OpsErrorFilter { + route: matching.route.clone(), + ..Default::default() + }, + ), + ( + "model", + OpsErrorFilter { + model: matching.model.clone(), + ..Default::default() + }, + ), + ( + "status", + OpsErrorFilter { + status_code: matching.status_code, + ..Default::default() + }, + ), + ( + "client status", + OpsErrorFilter { + client_status_code: matching.client_status_code, + ..Default::default() + }, + ), + ( + "upstream status", + OpsErrorFilter { + upstream_status_code: matching.upstream_status_code, + ..Default::default() + }, + ), + ( + "transport", + OpsErrorFilter { + transport: matching.transport.clone(), + ..Default::default() + }, + ), + ( + "attempt", + OpsErrorFilter { + attempt_index: matching.attempt_index, + ..Default::default() + }, + ), + ( + "failure", + OpsErrorFilter { + failure_class: matching.failure_class.clone(), + ..Default::default() + }, + ), + ( + "response", + OpsErrorFilter { + response_id: matching.response_id.clone(), + ..Default::default() + }, + ), + ( + "upstream request", + OpsErrorFilter { + upstream_request_id: matching.upstream_request_id.clone(), + ..Default::default() + }, + ), + ( + "message search", + OpsErrorFilter { + search: Some("needle failure".to_string()), + ..Default::default() + }, + ), + ( + "request search", + OpsErrorFilter { + search: matching.request_id.clone(), + ..Default::default() + }, + ), + ( + "response search", + OpsErrorFilter { + search: matching.response_id.clone(), + ..Default::default() + }, + ), + ( + "upstream search", + OpsErrorFilter { + search: matching.upstream_request_id.clone(), + ..Default::default() + }, + ), + ( + "time window", + OpsErrorFilter { + start_time: Some(now - Duration::minutes(1)), + end_time: Some(now + Duration::minutes(1)), + ..Default::default() + }, + ), + ]; + for (dimension, filter) in filters { + let page = store.list_page(filter, 1, 20).await.unwrap(); + assert_eq!(page.total, 1, "{dimension}"); + assert_eq!(page.items[0].id, matching.id, "{dimension}"); + } +} + #[tokio::test] async fn ops_error_and_bucket_are_one_transaction() { let (pool, _guard) = init_test_db("ops-error-transaction").await; @@ -59,3 +253,42 @@ async fn ops_error_and_bucket_are_one_transaction() { .unwrap(); assert_eq!(count, 0); } + +#[tokio::test] +async fn error_bucket_service_tier_should_match_rebuild_semantics() { + let (pool, _guard) = init_test_db("ops-error-tier-rebuild").await; + sqlx::query( + "insert into runtime_settings ( + id, refresh_margin_seconds, refresh_concurrency, + max_concurrent_per_account, request_interval_ms, rotation_strategy, updated_at + ) values (1, 3600, 2, 1, 0, 'smart', now())", + ) + .execute(&pool) + .await + .unwrap(); + let store = PgOpsErrorLogStore::new(pool.clone()); + let mut error = OpsErrorLog::new("upstream", "tier must not drift"); + error.id = "ops_error_tier".to_string(); + error.provider = Some("openai".to_string()); + error.account_id = Some("acct_tier".to_string()); + error.model = Some("gpt-5".to_string()); + error.service_tier = Some("priority".to_string()); + store.append(&error).await.unwrap(); + + let live_tier: String = + sqlx::query_scalar("select service_tier from request_time_buckets where error_count = 1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(live_tier, "__unknown__"); + + codex_proxy_rs::telemetry::rebuild::rebuild_buckets(&pool) + .await + .unwrap(); + let rebuilt_tier: String = + sqlx::query_scalar("select service_tier from request_time_buckets where error_count = 1") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rebuilt_tier, live_tier); +} diff --git a/backend/tests/telemetry/usage/store.rs b/backend/tests/telemetry/usage/store.rs index 7562a4933..87bb94720 100644 --- a/backend/tests/telemetry/usage/store.rs +++ b/backend/tests/telemetry/usage/store.rs @@ -37,6 +37,174 @@ async fn usage_store_should_filter_numbered_pages_of_success_facts() { assert_eq!(page.items[0].request_id.as_deref(), Some("req_1")); } +#[tokio::test] +async fn usage_store_should_apply_every_query_filter_dimension() { + let (pool, _guard) = init_test_db("usage-record-filter-matrix").await; + let store = PgUsageRecordStore::new(pool); + let now = Utc::now(); + let mut matching = success_record("needle message"); + matching.id = "usage_matrix_matching".to_string(); + matching.kind = "response".to_string(); + matching.client_api_key_id = Some("key_matching".to_string()); + matching.provider = "provider_matching".to_string(); + matching.request_id = Some("request_matching".to_string()); + matching.account_id = "account_matching".to_string(); + matching.route = Some("/v1/matching".to_string()); + matching.model = "model_matching".to_string(); + matching.status_code = 201; + matching.transport = Some("websocket".to_string()); + matching.attempt_index = Some(3); + matching.response_id = Some("response_matching".to_string()); + matching.upstream_request_id = Some("upstream_matching".to_string()); + matching.created_at = now; + store.append(&matching).await.unwrap(); + + let mut other = success_record("other message"); + other.id = "usage_matrix_other".to_string(); + other.kind = "other".to_string(); + other.client_api_key_id = Some("key_other".to_string()); + other.provider = "provider_other".to_string(); + other.request_id = Some("request_other".to_string()); + other.account_id = "account_other".to_string(); + other.route = Some("/v1/other".to_string()); + other.model = "model_other".to_string(); + other.status_code = 202; + other.transport = Some("http_sse".to_string()); + other.attempt_index = Some(4); + other.response_id = Some("response_other".to_string()); + other.upstream_request_id = Some("upstream_other".to_string()); + other.created_at = now - Duration::hours(2); + store.append(&other).await.unwrap(); + + let filters = vec![ + ( + "kind", + UsageRecordFilter { + kind: Some(matching.kind.clone()), + ..Default::default() + }, + ), + ( + "client key", + UsageRecordFilter { + client_api_key_id: matching.client_api_key_id.clone(), + ..Default::default() + }, + ), + ( + "provider", + UsageRecordFilter { + provider: Some(matching.provider.clone()), + ..Default::default() + }, + ), + ( + "request", + UsageRecordFilter { + request_id: matching.request_id.clone(), + ..Default::default() + }, + ), + ( + "account", + UsageRecordFilter { + account_id: Some(matching.account_id.clone()), + ..Default::default() + }, + ), + ( + "route", + UsageRecordFilter { + route: matching.route.clone(), + ..Default::default() + }, + ), + ( + "model", + UsageRecordFilter { + model: Some(matching.model.clone()), + ..Default::default() + }, + ), + ( + "status", + UsageRecordFilter { + status_code: Some(matching.status_code), + ..Default::default() + }, + ), + ( + "transport", + UsageRecordFilter { + transport: matching.transport.clone(), + ..Default::default() + }, + ), + ( + "attempt", + UsageRecordFilter { + attempt_index: matching.attempt_index, + ..Default::default() + }, + ), + ( + "response", + UsageRecordFilter { + response_id: matching.response_id.clone(), + ..Default::default() + }, + ), + ( + "upstream request", + UsageRecordFilter { + upstream_request_id: matching.upstream_request_id.clone(), + ..Default::default() + }, + ), + ( + "message search", + UsageRecordFilter { + search: Some("needle message".to_string()), + ..Default::default() + }, + ), + ( + "request search", + UsageRecordFilter { + search: matching.request_id.clone(), + ..Default::default() + }, + ), + ( + "response search", + UsageRecordFilter { + search: matching.response_id.clone(), + ..Default::default() + }, + ), + ( + "upstream search", + UsageRecordFilter { + search: matching.upstream_request_id.clone(), + ..Default::default() + }, + ), + ( + "time window", + UsageRecordFilter { + start_time: Some(now - Duration::minutes(1)), + end_time: Some(now + Duration::minutes(1)), + ..Default::default() + }, + ), + ]; + for (dimension, filter) in filters { + let page = store.list_page(filter, 1, 20).await.unwrap(); + assert_eq!(page.total, 1, "{dimension}"); + assert_eq!(page.items[0].id, matching.id, "{dimension}"); + } +} + #[tokio::test] async fn usage_store_should_list_recent_records_in_descending_order() { let (pool, _guard) = init_test_db("usage-record-recent").await; @@ -73,11 +241,12 @@ async fn usage_record_and_bucket_are_one_transaction_and_keep_zero_ms_samples() record.first_token_ms = Some(0); record.input_tokens = Some(11); record.output_tokens = Some(7); + record.service_tier = Some("priority".to_string()); store.append(&record).await.unwrap(); - let bucket: (i64, i64, i64, i64, i64) = sqlx::query_as( + let bucket: (i64, i64, i64, i64, i64, String) = sqlx::query_as( "select success_count, input_tokens, output_tokens, latency_count, - first_token_latency_count + first_token_latency_count, service_tier from request_time_buckets where account_id = $1 and model = $2", ) .bind(&record.account_id) @@ -85,7 +254,7 @@ async fn usage_record_and_bucket_are_one_transaction_and_keep_zero_ms_samples() .fetch_one(&pool) .await .unwrap(); - assert_eq!(bucket, (1, 11, 7, 1, 1)); + assert_eq!(bucket, (1, 11, 7, 1, 1, "priority".to_string())); sqlx::raw_sql( "create function reject_test_bucket() returns trigger language plpgsql as $$ diff --git a/backend/tests/upstream/openai/transport/websocket_pool.rs b/backend/tests/upstream/openai/transport/websocket_pool.rs index 81d68a289..eda6cbc79 100644 --- a/backend/tests/upstream/openai/transport/websocket_pool.rs +++ b/backend/tests/upstream/openai/transport/websocket_pool.rs @@ -84,6 +84,7 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke let addr = listener.local_addr().unwrap(); let accepted_connections = Arc::new(AtomicUsize::new(0)); let accepted_connections_for_server = Arc::clone(&accepted_connections); + let (first_closed_tx, first_closed_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { // 第一条连接:完成一次响应后由服务端主动关闭(模拟 idle 期间被上游/中间盒断开)。 let (first_stream, _) = listener.accept().await.unwrap(); @@ -97,6 +98,8 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke .await .unwrap(); first_websocket.close(None).await.unwrap(); + let _ = first_websocket.next().await; + first_closed_tx.send(()).unwrap(); // 第二条连接:证明复用被跳过、直接新建。 let (second_stream, _) = listener.accept().await.unwrap(); @@ -138,8 +141,10 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke ) .await .expect("first pooled websocket response should succeed"); - // 给后台 pump 一点时间观察到服务端的 close 帧并标记连接死亡。 - tokio::time::sleep(Duration::from_millis(50)).await; + first_closed_rx + .await + .expect("client pump should acknowledge the upstream close"); + tokio::task::yield_now().await; let second = backend .create_response( &request, @@ -157,7 +162,7 @@ async fn codex_backend_client_should_open_fresh_socket_when_idle_pooled_websocke assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn codex_backend_client_stream_should_keep_fresh_socket_after_structural_activity() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -311,6 +316,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_ ) .await .expect("seed response should populate pool"); + tokio::time::pause(); let response = backend .create_response_stream( &request, @@ -335,7 +341,7 @@ async fn codex_backend_client_stream_should_keep_reused_socket_after_structural_ assert_eq!(accepted_connections.load(Ordering::SeqCst), 1); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn codex_backend_client_stream_should_keep_bypass_socket_after_structural_activity() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -592,7 +598,7 @@ async fn websocket_pool_should_bypass_busy_key_with_one_shot_connections() { ); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn websocket_pool_should_release_fresh_reservation_when_prefetch_is_cancelled() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -655,7 +661,7 @@ async fn websocket_pool_should_release_fresh_reservation_when_prefetch_is_cancel assert_eq!(second.websocket_pool_decision.unwrap().kind(), "new"); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn websocket_pool_should_release_slot_when_client_drops_stream() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -830,18 +836,22 @@ async fn websocket_pool_should_evict_idle_connection_when_liveness_lapses_despit let addr = listener.local_addr().unwrap(); let accepted_connections = Arc::new(AtomicUsize::new(0)); let accepted_connections_for_server = Arc::clone(&accepted_connections); + let (release_first_tx, release_first_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { let (first_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); - let mut first_websocket = accept_codex_test_websocket(first_stream).await; - let _first_message = first_websocket.next().await.unwrap().unwrap(); - first_websocket - .send(Message::Text( - completed_websocket_response("resp_no_pong_first", 2, 1).into(), - )) - .await - .unwrap(); - // 故意不再读取该连接:既不回 pong,也不发任何帧,模拟静默失活。 + let first_connection = tokio::spawn(async move { + let mut first_websocket = accept_codex_test_websocket(first_stream).await; + let _first_message = first_websocket.next().await.unwrap().unwrap(); + first_websocket + .send(Message::Text( + completed_websocket_response("resp_no_pong_first", 2, 1).into(), + )) + .await + .unwrap(); + let _ = release_first_rx.await; + }); + let (second_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); let mut second_websocket = accept_codex_test_websocket(second_stream).await; @@ -853,7 +863,7 @@ async fn websocket_pool_should_evict_idle_connection_when_liveness_lapses_despit .await .unwrap(); second_websocket.close(None).await.unwrap(); - drop(first_websocket); + first_connection.await.unwrap(); }); let pool = Arc::new(CodexWebSocketPool::with_config(CodexWebSocketPoolConfig { ping_interval: Some(Duration::from_millis(1)), @@ -876,8 +886,9 @@ async fn websocket_pool_should_evict_idle_connection_when_liveness_lapses_despit ) .await .expect("first websocket response should succeed"); - // 等待 pump 的 liveness watchdog 判定静默连接失活。 - tokio::time::sleep(Duration::from_millis(60)).await; + tokio::time::pause(); + tokio::time::advance(Duration::from_millis(25)).await; + tokio::task::yield_now().await; let second = backend .create_response( &request, @@ -885,14 +896,16 @@ async fn websocket_pool_should_evict_idle_connection_when_liveness_lapses_despit ) .await .expect("second websocket response should use a fresh connection"); + release_first_tx.send(()).unwrap(); server.await.unwrap(); assert!(first.body.contains("resp_no_pong_first")); assert!(second.body.contains("resp_no_pong_second")); + assert_eq!(second.websocket_pool_decision.unwrap().kind(), "new"); assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn websocket_pool_should_gc_expired_idle_connections() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -966,7 +979,7 @@ async fn websocket_pool_should_gc_expired_idle_connections() { assert_eq!(accepted_connections.load(Ordering::SeqCst), 2); } -#[tokio::test] +#[tokio::test(start_paused = true)] async fn codex_backend_client_should_keep_idle_pooled_websocket_alive_across_repeated_pings() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -1295,6 +1308,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_ let addr = listener.local_addr().unwrap(); let accepted_connections = Arc::new(AtomicUsize::new(0)); let accepted_connections_for_server = Arc::clone(&accepted_connections); + let (liveness_closed_tx, liveness_closed_rx) = tokio::sync::oneshot::channel(); let server = tokio::spawn(async move { let (first_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); @@ -1326,6 +1340,7 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_ .expect("liveness timeout should send a close frame") .expect("close frame should be valid"); assert!(matches!(close, Message::Close(_))); + liveness_closed_tx.send(()).unwrap(); let (second_stream, _) = listener.accept().await.unwrap(); accepted_connections_for_server.fetch_add(1, Ordering::SeqCst); @@ -1379,7 +1394,9 @@ async fn codex_backend_client_should_close_idle_pooled_websocket_after_liveness_ ) .await .expect("first pooled websocket response should succeed"); - tokio::time::sleep(Duration::from_millis(10)).await; + liveness_closed_rx + .await + .expect("liveness watchdog should close the idle connection"); pool.maintain_idle_connections().await; let second = backend .create_response( diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 708e0b927..9b09fdb92 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -8,12 +8,13 @@ x-json-logging: &json-logging services: postgres: - image: postgres:18-alpine@sha256:9a8afca54e7861fd90fab5fdf4c42477a6b1cb7d293595148e674e0a3181de15 + image: postgres:18-bookworm@sha256:d9c83446333daec3f0588cc709adb80c26090b7f9f0f7ec8d43c243385d79818 restart: unless-stopped environment: POSTGRES_DB: codex_proxy POSTGRES_USER: codex_proxy POSTGRES_PASSWORD: ${CPR_POSTGRES_PASSWORD:?CPR_POSTGRES_PASSWORD must be set in deploy/.env} + POSTGRES_INITDB_ARGS: --auth-local=scram-sha-256 --auth-host=scram-sha-256 volumes: - postgres-data:/var/lib/postgresql ports: diff --git a/docs/audit-index.md b/docs/audit-index.md new file mode 100644 index 000000000..da45e0487 --- /dev/null +++ b/docs/audit-index.md @@ -0,0 +1,48 @@ +# 审查文档索引(codex-proxy-rs) + +> 汇总于 `feat/postgres-redis-migration` 工作树。本目录下 10 份 `*-review.md` 是对 backend/frontend/deploy/CI 的分领域审查,统一采用 P0/P1/P2 分级 + "做得好的" + "核实过的误报" + "建议动手顺序" 的结构。本文件是导航 + 跨领域交叉印证,不重复各文档正文。 + +## 10 份审查一览 + +| 文档 | 领域 | 最高优先级发现(一句话) | +| --- | --- | --- | +| [security-review.md](security-review.md) | 安全 | 上游 OAuth token 与 client API key 明文落库;转发头无条件信任致登录锁定可绕过 | +| [concurrency-review.md](concurrency-review.md) | 并发/锁/错误处理 | **P0** 客户端首 token 前取消会永久泄漏 WS 池 Busy 槽位,账号连接池静默失效 | +| [storage-review.md](storage-review.md) | 数据库/Redis/迁移 | **P0** `CURRENT_SCHEMA_VERSION` 手工同步,第一次加列漏改即"上线成功、重启失败" | +| [api-contract-review.md](api-contract-review.md) | HTTP API 契约 | **P0** 管理台 keys 页只有 cursor 分页而前端不跟游标,>50 个 key 被静默截断 | +| [logging-review.md](logging-review.md) | 日志 | **P0** 无 stdout 层致 `docker logs` 全空;全项目零 `error!` | +| [frontend-review.md](frontend-review.md) | 前端工程 | 无 ESLint(唯一门禁是 prettier);401 会话过期无跳转,页面永不回登录页 | +| [test-coverage-review.md](test-coverage-review.md) | 测试覆盖 | 存储故障注入 / 并发争用 / telemetry 三块几乎零直测 | +| [dependency-review.md](dependency-review.md) | 依赖 | `config` 默认 features 全开编死重;`sqlx` sqlite 走 bundled 编进生产二进制 | +| [deploy-ci-review.md](deploy-ci-review.md) | 部署/CI | 每次 push 两次全冷 Docker 构建;Redis 无密码;日志无上限 | +| [naming-review.md](naming-review.md) | 命名 | 仅剩 5 处 `foo.rs + foo/` 模块风格未统一到 `mod.rs` | +| [dead-code-and-smells-review.md](dead-code-and-smells-review.md) | 死代码/异味 | 死代码仅 4 个纯委托 wrapper;2 处锁持有过久(与并发审查呼应) | + +## 跨领域交叉印证(多份文档指向同一根因) + +这几处不是孤立发现,而是从不同角度撞到了同一件事,修复时应合并考虑: + +1. **明文凭据 = 安全 × 存储**:上游 token / client key 明文落库,[security](security-review.md) 从"资产裸奔 + 与 admin key 摘要做法不一致"定性,[storage](storage-review.md) 从建表 DDL(`0001_initial.sql:12,46-47`)佐证。若做 client key 摘要化,会**需要一条真实迁移**——正好撞上 storage P0 的迁移框架加固;两件事应排在同一个迁移窗口。 + +2. **客户端取消 = 并发 × 测试盲区**:[concurrency](concurrency-review.md) 的 P0(WS 槽位泄漏)+ P1(账号 slot 虚占)是"acquire 后无 Drop 兜底"的同一根因;而 [test-coverage](test-coverage-review.md) 恰好指出"并发争用 / 取消路径零覆盖"——这条 bug 能长期潜伏正是因为没有取消测试。修复 PR 应同时补一个"首 token 前 drop 请求 future"的回归测试。 + +3. **双分页 = API × 前端**:[api-contract](api-contract-review.md) 的 P0(keys 页 cursor 截断)与 [frontend](frontend-review.md) 的"API 层类型断层"是同一条边界的两面——后端给 cursor、前端不跟且类型被 any 击穿。收敛到 numbered 分页需要前后端一起改。 + +4. **sqlite 依赖 = 存储 × 依赖**:[storage](storage-review.md) 从"import_sqlite 退役条件已满足"、[dependency](dependency-review.md) 从"sqlx sqlite bundled 编译成本"两头指向同一动作;且两份都独立发现 **`sqlx uuid` feature 全仓零使用**(可即刻删)。 + +5. **无上限增长 = 日志 × 部署 × 存储**:[logging](logging-review.md) 的文件日志无大小上限、[deploy-ci](deploy-ci-review.md) 的 Docker `json-file` 无 `max-size`、[storage](storage-review.md) 的 affinity SET 只增不减——三处都是"长期运行吃满资源"的同类隐患,运维加固时一并处理。 + +## 跨全部审查的 P0 清单(建议最先处理) + +按"可匿名利用 / 不可自愈 / 数据可见性"排序: + +1. **转发头信任边界**([security](security-review.md) P1 但**匿名可直接利用**)—— 唯一在线爆破防线失效,改动集中在 `request_id.rs` 一处。**投入产出最高,建议第一个做。** +2. **WS 槽位取消泄漏**([concurrency](concurrency-review.md) P0)—— 唯一"不可自愈的实际 bug",连接池静默失效只能重启恢复。 +3. **迁移版本常量漂移**([storage](storage-review.md) P0)—— 一行改动消灭"第一次加列就翻车"的确定性事故,且是后续所有加密/加列迁移的前置。 +4. **keys 页分页截断**([api-contract](api-contract-review.md) P0)—— 唯一真数据可见性 bug,管理台超 50 key 不可见。 +5. **stdout 日志层缺失**([logging](logging-review.md) P0)—— 容器日志采集全线接不上,排障基础设施缺位。 +6. **上游 token 明文**([security](security-review.md) P0)—— 资产价值最高,但属 latent(需先能读 PG),改动面最大,单独排期。 + +## 整体评价 + +跨 10 个领域看下来,这是一个**工程素养明显在线**的代码库:鉴权原语(argon2 / ConstantTimeEq / PKCE / 摘要化会话)、锁纪律(std 锁无一跨 await、poison 处理统一)、事务契约(事实+桶同事务)、迁移防御(单事务 / 防降级)、测试基建(每测试独立真库)都达到或超过行业基线。发现的问题**集中在边界与演进**:取消安全、信任边界、第一次 schema 演进、容器日志出口——都是"正常路径写得好、异常/演进路径没兜住"的类型,而非架构性硬伤。多份审查各自独立地把预设的部分"疑似问题"核实为误报(strict 已开、recovery 路径其实测得最密、时序 oracle 不可利用、迁移框架其实存在),说明这套代码经得起推敲。 diff --git a/docs/database.md b/docs/database.md index 3f5540006..5b60de045 100644 --- a/docs/database.md +++ b/docs/database.md @@ -3,7 +3,7 @@ 本文档是 Codex Proxy RS 存储层的**唯一**权威文档:终态形态为 **PostgreSQL(持久层)+ Redis(运行态/缓存)**。全量定义 PG Schema、Redis 键契约、全局纪律、统计口径、SQLite → PG 的一次性搬迁方案与代码改造清单。 - 取代 2026-07-08 版(SQLite 单进程形态 + 0004 迁移草案)。旧版描述的 v4 终态 Schema 语义**全部保留**,宿主引擎由 SQLite 换为 PG + Redis,0004 迁移方案改由导入命令吸收。 -- 基线:生产 SQLite 库 schema v3(读侧,只读打开);PG 库从 0001 终态基线全新建立(写侧)。 +- 基线:生产 SQLite 库 schema v3(读侧,只读打开);尚未上线的 PG 谱系由 0001 直接建立当前 v1 终态(写侧)。 - 策略:**一次破坏性切换直达终态,不做兼容层**。没有双写期、没有 SQLite 回退路径、没有查询层兼容过滤;不满足终态契约的历史数据按 §6.3 规则导入或丢弃,丢弃项全部显式列出。 --- @@ -113,7 +113,7 @@ 1. **每个键有确定的过期路径**:TTL(会话、租约、亲和)或"最新态覆盖"(缓存 HASH)。禁止只增不减的键空间。 2. **可丢弃性是准入条件**:FLUSHALL 之后系统必须无数据损失地继续运行(会话重登、租约重竞争、亲和退化为普通调度、缓存重拉)。不满足即不属于 Redis。 3. **互斥必须原子**:获取用 `SET NX PX`,续约/释放用 Lua 比较 owner 后操作,禁止 GET-判断-SET 三段式。 -4. 二级索引(亲和的 conversation ZSET / account SET)由写入方维护,读取方容忍成员悬垂(主键已过期),遇悬垂惰性清理。 +4. 二级索引(亲和的 conversation / account ZSET)由写入方维护,读取方容忍成员悬垂(主键已过期),遇悬垂惰性清理。 5. 单个业务对象统一编码为完整 JSON 文本,不把对象属性拆成 Redis Hash 字段。集合缓存可使用 HASH(如 `cpr:models:plan_snapshots`),但每个 field 的 value 仍是一个完整 JSON 对象,整读整写。 ### 2.10 保留与可重建性(诚实边界) @@ -125,16 +125,15 @@ | `request_time_buckets` | 90 天(默认) | `runtime_settings.bucket_retention_days`,同上 | | `account_cookies` | 按 `expires_at` 清理 | 周期任务(PG) | | `account_usage` | 每账号 1 行 | 账号删除时 FK 级联删除 | -| `account_model_usage` | 每账号最近 100 个模型 | 每小时按 `last_used_at desc nulls last, model` 裁剪 | | `fingerprints` | 当前指纹固定 1 行 | 固定 ID 覆盖更新 | | `fingerprint_update_history` | 全局最近 100 行 | 每小时按 `created_at desc, id desc` 裁剪 | | Redis 会话/租约/亲和 | TTL 自然过期 | 无清理任务 | | 其余 PG 实体表 | 随管理实体数量增长 | 不随请求数增长 | -- trim 是**每小时执行的后台任务**:三张时间表每轮读 `runtime_settings` 保留列,两张历史/统计表使用上表的固定行数上限。不再在每次写入后内联执行——v3 的"每写一次 delete 一次"是已知的写放大缺陷,终态修复。 +- trim 是**每小时执行的后台任务**:三张时间表每轮读 `runtime_settings` 保留列,指纹历史使用上表的固定行数上限。不再在每次写入后内联执行——v3 的"每写一次 delete 一次"是已知的写放大缺陷,终态修复。 - 30/30/90 天是数据时间窗,不是字节硬上限;高流量下时间窗内的事实仍可很大。PostgreSQL `autovacuum` 会回收 DELETE 后的页供后续复用,但不保证数据文件立即缩小;不周期执行会锁表的 `VACUUM FULL`。 - `request_time_buckets` 在事实保留期内可由两张事实表重算(`rebuild-buckets` 命令,§7);超出事实保留期的桶是**因事实过期而升格的一级数据**,写入纪律 + 同事务写入(§5.2)是它们的唯一保障。 -- `account_usage` / `account_model_usage` 的累计列**不可重建**(事实早已过期),同样只受写入纪律保护。模型维度超过 100 后丢弃最久未用的分项累计,账号总累计仍由 `account_usage` 保留。本文档不声称"一切派生皆可重算"。 +- `account_usage` 的累计列**不可重建**(事实早已过期),只受写入纪律保护。模型窗口分布直接从 `request_time_buckets` 查询,不再维护无生产读者的第二份 `account_model_usage` 累计。本文档不声称"一切派生皆可重算"。 - `window_*` 列是运行态(窗口边界来自上游),不可重建,可丢弃。 --- @@ -156,7 +155,6 @@ │ │ usage_records(成功) · ops_error_logs(失败) │ │ │ └─ 派生(口径见 §2.10)──────────────────────────┘ │ │ request_time_buckets · account_usage │ -│ account_model_usage │ └─────────────────────────────────────────────────────┘ ┌─ Redis(运行态/缓存,可丢弃)─────────────────────────┐ │ cpr:admin:session:* 管理端会话(TTL) │ @@ -168,7 +166,6 @@ ```text accounts 1──1 account_usage FK cascade(PK=FK) -accounts 1──< account_model_usage FK cascade(复合 PK 前缀) accounts 1──< account_cookies FK cascade(account_domain 前缀) fingerprints 1──< fingerprint_update_history FK cascade(idx_…_fingerprint) @@ -176,7 +173,7 @@ usage_records.{account_id, client_api_key_id} → 逻辑引用,无 FK ops_error_logs.{account_id, client_api_key_id} → 逻辑引用,无 FK request_time_buckets.account_id → 逻辑维度,无 FK -Redis 亲和.account_id → 账号删除时经 cpr:affinity:account: 索引显式清理(§4B.3) +Redis 亲和.account_id → 账号删除时经 cpr:affinity:v2:account: 索引显式清理(§4B.3) Redis 租约.account_id → 不清理,TTL(分钟级)自然过期 ``` @@ -188,16 +185,17 @@ Redis 租约.account_id → 不清理,TTL(分钟级)自然过期 ## 4. PostgreSQL 表设计(终态 DDL) -以下 DDL 即 `infra/migrations/0001_initial.sql`(PG 基线)。PG 库从此基线全新建立;历史数据经导入命令进入(§6),不存在 SQLite → PG 的迁移链。 +以下 DDL 即 `infra/migrations/0001_initial.sql` 描述的 PG v1 终态。PG 库由迁移框架从空库建立;历史数据经导入命令进入(§6),不存在 SQLite → PG 的原地迁移链。 ### 4.1 schema_migrations -迁移版本的唯一事实来源。由 `database.rs` 建库时创建,不走迁移文件。只增不改;已发布迁移永不修改;`CURRENT_SCHEMA_VERSION` 与迁移列表同步提交。PG 谱系从 1 重新编号,与 SQLite 谱系(止于 3)无延续关系。 +迁移版本的唯一事实来源。由 `database.rs` 建库时创建,不走迁移文件。已发布迁移只增不改;当前版本从 `MIGRATIONS.last()` 派生,不存在手工版本常量。启动时先校验内置版本严格递增且不重复,再校验数据库中的已应用记录是内置迁移的有序前缀,并逐条核对名称与 SHA-256 SQL checksum。PG 谱系从 1 重新编号,与 SQLite 谱系(止于 3)无延续关系。 ```sql create table schema_migrations ( version bigint not null check (version > 0), name text not null, + checksum text not null, applied_at timestamptz not null default now(), primary key (version) ); @@ -302,7 +300,6 @@ create table accounts ( updated_at timestamptz not null ); -create index idx_accounts_status on accounts(status); create index idx_accounts_added_id on accounts(added_at desc, id desc); create unique index ux_accounts_chatgpt_identity on accounts(chatgpt_account_id, coalesce(chatgpt_user_id, '')) @@ -348,9 +345,6 @@ create table account_usage ( limit_window_seconds bigint check (limit_window_seconds is null or limit_window_seconds > 0), last_used_at timestamptz ); - -create index idx_account_usage_last_used_account - on account_usage(last_used_at desc, account_id desc); ``` | 组 | 说明 | @@ -361,32 +355,7 @@ create index idx_account_usage_last_used_account 宽表(20 计数列)而非 EAV:计数种类是代码常量,宽表一次 upsert 全更新、check 逐列可写;EAV 只换来动态 SQL 和类型丢失。 -### 4.7 account_model_usage - -账号 × 模型分布,服务账号详情页与调度辅助。自然复合键,不引入代理 id。 - -```sql -create table account_model_usage ( - account_id text not null references accounts(id) on delete cascade, - model text not null, - request_count bigint not null default 0 check (request_count >= 0), - error_count bigint not null default 0 check (error_count >= 0), - input_tokens bigint not null default 0 check (input_tokens >= 0), - output_tokens bigint not null default 0 check (output_tokens >= 0), - cached_tokens bigint not null default 0 check (cached_tokens >= 0), - last_used_at timestamptz, - primary key (account_id, model) -); - -create index idx_account_model_usage_last_used - on account_model_usage(last_used_at desc, account_id, model); -``` - -`error_count` 是**收窄口径**:仅统计已归属到账号+模型之后的失败,用作调度回避信号(频繁失败 → 降权)。这是全库唯一允许的口径重叠(与 ops_error_logs),因为用途不同(调度局部信号 vs 全局错误事实);Dashboard 错误率禁止取自本列。 - -每小时按 `last_used_at desc nulls last, model` 为每个账号保留前 100 行。该硬上限限制历史模型名持续增长,不改变 `account_usage` 的账号总累计。 - -### 4.8 usage_records ★ +### 4.7 usage_records ★ **每个成功的客户端请求恰好一行**。用量、token、成本、账号与调用方归因的唯一事实来源。这是系统的账本;"只收成功"的边界由 DB 约束兜底,不再只靠 service 层。 @@ -451,7 +420,7 @@ create index idx_usage_records_upstream_request_id on usage_records(upstream_req **禁止**:写入失败事件;token 写回 metadata 当真相;加任何兼容过滤查询历史数据。 -### 4.9 ops_error_logs ★ +### 4.8 ops_error_logs ★ **每个失败请求/运维错误恰好一行**。与 usage_records 互斥:一次请求终态只进一张表。失败与成功的查询维度完全不同(阶段/分类/归责 vs token/成本),拆表让两边 schema 各自朝自己的查询模型演进。 @@ -505,7 +474,7 @@ create index idx_ops_error_logs_upstream_request_id on ops_error_logs(upstream_r **预留演进列(有 UI/告警需求时增列,刻意不预建,本清单为唯一权威版本)**:`surface`、`error_phase`、`error_type`、`severity`、`requested_model` / `upstream_model` / `service_tier`、`client_ip` / `user_agent`、`error_source` / `error_owner`、`is_retryable` / `retry_count`。原则:字段跟着真实查询需求走,不照搬外部项目全集。 -### 4.10 request_time_buckets ★ +### 4.9 request_time_buckets ★ 15 分钟 × provider × 账号 × 模型 × service_tier 预聚合,Dashboard 趋势与流量卡片的唯一数据源。**单表同时服务两种口径**:traffic(success + error)与 usage(token,仅成功)。曾有双表方案,已否决:每请求两次 upsert 的写放大不值得;分列 + 写入纪律已同时提供两种口径,rebuild 命令兜底(保留期内,§2.10)。 @@ -530,8 +499,6 @@ create table request_time_buckets ( updated_at timestamptz not null, primary key (bucket_start, provider, account_id, model, service_tier) ); - -create index idx_request_time_buckets_model on request_time_buckets(model, bucket_start); ``` | 字段 | 说明 | @@ -545,9 +512,11 @@ create index idx_request_time_buckets_model on request_time_buckets(model, bucke **写入事务纪律**:事实 insert 与桶 upsert 必须同事务(§5.2)。 +**索引决策**:终态只保留以 `bucket_start` 为首列的主键索引。2026-07-12 在本地真实迁移数据(672 个桶)上对生产时间窗聚合执行 `EXPLAIN ANALYZE`:保留 `idx_request_time_buckets_model` 时为顺序扫描、`0.955 ms`;事务内删除后仍为相同顺序扫描、`0.764 ms`。现有三条查询均不以 `model` 等值作为前导条件,因此尚未上线的 0001 基线不创建该无读者索引。 + **禁止**:错误路径写 token/延迟列;落库 `request_count`;空字符串维度。 -### 4.11 account_cookies +### 4.10 account_cookies 账号维度上游 Cookie(部分上游流程需要浏览器态凭据)。语义直接采用 RFC 6265 模型;unique 约束 = RFC 替换语义,upsert 依据。 @@ -570,7 +539,7 @@ create index idx_account_cookies_expires on account_cookies(expires_at) where ex `value` 是 secret(§2.6)。`expires_at` NULL = 会话 Cookie,不参与清理。**过期时间在捕获时解析为 timestamptz**(RFC2822/RFC3339 均接受,解析失败按会话 Cookie 落 NULL)——v3 存原始字符串再做字典序比较是已知缺陷,终态修复。 -### 4.12 fingerprints +### 4.11 fingerprints 设备指纹模板(UA、header 集合与顺序——顺序本身是指纹的一部分)。行数常量级、按 PK 取用,**无二级索引**。版本三元组用 text:版本号不是数字(`1.2.3-beta`)。 @@ -592,7 +561,7 @@ create table fingerprints ( ); ``` -### 4.13 fingerprint_update_history +### 4.12 fingerprint_update_history 指纹更新审计。版本快照列固化"当时更新到了什么"——不能只存 FK,指纹行会被后续更新覆写。级联删除可接受:指纹本体删除后其历史无独立价值。 @@ -650,21 +619,27 @@ TTL 租约时长(acquire 时以 PX 设定) ### 4B.3 会话亲和 ```text -主键 cpr:affinity:resp: +主键 cpr:affinity:v2:resp: 值 {"accountId","conversationId","turnState","instructionsHash", - "inputTokens","functionCallIds":[…],"variantHash","createdAt"} + "inputTokens","functionCallIds":[…],"variantHash","continuationScope", + "replay":{"parentResponseId","turnInput","turnOutput","depth","totalBytes"}, + "createdAt"} TTL 亲和 TTL(默认 4h),EXPIREAT createdAt+ttl -索引 cpr:affinity:conv: ZSET member=response_id score=createdAt(epoch ms) -索引 cpr:affinity:account: SET member=response_id +索引 cpr:affinity:v2:conv: ZSET member=response_id score=createdAt(epoch ms) +索引 cpr:affinity:v2:account: ZSET member=response_id score=createdAt(epoch ms) ``` -- 写入(记录一次成功响应):MULTI 内 `SET resp EXAT` + `ZADD conv` + `EXPIRE conv ttl` + `SADD account` + `EXPIRE account ttl`,并顺手 `ZREMRANGEBYSCORE conv -inf now-ttl` 惰性剪枝。 +- `v2` 明确隔离旧的累计 `fullInput` 编码,不读取旧键。 +- 写入(记录一次成功响应):MULTI 内 `SET resp EXAT` + 两个 `ZADD` + 两个 `EXPIRE`,并对 conversation/account 索引执行 `ZREMRANGEBYSCORE -inf now-ttl` 惰性剪枝。 - 按 response_id 查:GET 主键。按 conversation 查最新:`ZREVRANGE conv` 取候选 → GET 主键过滤 variant_hash / max_age → 第一个存活者;悬垂成员(主键已过期)当场 ZREM。 -- forget(response_id):GET 后 DEL 主键 + ZREM conv + SREM account。 -- **账号删除的级联**:SMEMBERS `cpr:affinity:account:` → 逐个 forget。等价于 v3 的 FK cascade + `idx_session_affinities_account`。 +- replay 每轮只保存本轮 `turnInput` + `turnOutput` 增量和父 response ID,读取时按父链重建;禁止在每个节点重复保存累计 `fullInput`,从而避免多轮会话 O(n²) 空间增长。重放数据递归删除 `encrypted_content`,并删除每个顶层 replay item 的上游 `id`;`call_id` 等协议关联字段保留。 +- 容量边界:客户端 `/v1` 请求体最大 16 MiB;单 replay 节点 JSON 最大 2 MiB;单会话累计最大 16 MiB;父链最大 128 层。任一边界超限即不再为该响应写 replay 基线,正常上游响应不受影响,不会为绕过边界而截断或篡改请求。 +- forget(response_id):GET 后在一次事务中 DEL 主键 + ZREM 两个索引。 +- **账号删除的级联**:一次 ZRANGE 取得账号 ZSET 成员,一次 MGET 读取归属,随后用单个 Redis 原子 pipeline 批量删除主键、conversation 成员和 account 索引;禁止逐成员网络往返。等价于 v3 的 FK cascade + `idx_session_affinities_account`。 - **进程内不再维护亲和映射副本**:Redis 本身就是快存,进程内再留一份只换来双写同步与重启恢复逻辑;亲和查找相对上游请求(百 ms 级)是 <1ms 的往返,可忽略。v3 的"重启恢复"步骤随之消失。 -- **禁止**承载会话历史/审计;全部键被清空只影响路由优化,不损失事实。 +- replay 是仅用于节点切换恢复的有界临时上下文,不是历史或审计事实;全部键被清空只会失去透明恢复能力,不会损失 PG 事实。 +- `identity_hmac_secret` 必须跨重启和镜像更换持久化。Docker 部署由 `/app/data` 挂载承载该文件;禁止把它放在容器可写层或每次启动重新生成。该 256-bit 密钥与账号 ID 分域派生稳定的 prompt/session/thread/window/installation 伪名,密钥变化会让全部账号身份同时漂移。 ### 4B.4 模型清单缓存 @@ -674,7 +649,7 @@ TTL 亲和 TTL(默认 4h),EXPIREAT createdAt+ttl TTL 无(最新态覆盖,HSET 即替换) ``` -缓存的正确形态,可随时清空重建(丢失 = 下次按需重拉)。**禁止**当模型目录事实表用;需要 diff/审计时新建 PG 历史表。 +缓存的正确形态,可随时清空重建(丢失 = 下次按需重拉)。刷新使用原子 `DEL + HSET` 全量替换,已从上游消失的 `plan_type` field 必须同步删除,不能只覆盖仍存在的字段。**禁止**当模型目录事实表用;需要 diff/审计时新建 PG 历史表。 --- @@ -689,7 +664,7 @@ traffic: request_count = success_count + error_count ← request_time_buck usage: token / 成本 ← usage_records 列(明细)、bucket token 列(趋势,仅成功) errors: 错误分布 / 排查 ← ops_error_logs 按 key: 调用方用量 / 失败 ← usage_records / ops_error_logs 的 client_api_key_id -调度负载: account_usage / account_model_usage ← 派生累计,不可重建(§2.10) +调度负载: account_usage ← 派生累计,不可重建(§2.10) ``` ### 5.2 写入路径与事务 @@ -704,7 +679,7 @@ errors: 错误分布 / 排查 ← ops_error 同一个 PG 事务 ``` -`account_usage` / `account_model_usage` 的 upsert **不在**上述事务内:它们由调度器在槽位释放 / 用量观测时点独立写入(与事实写入不在同一代码时刻),每笔 upsert 自身原子。这是对旧文档"四表同事务"的**显式修订**:强行合并需要把调度器的持久化时点搬进 dispatch 事务,复杂度换不来对账收益——两边口径本就允许亚秒级漂移,对账以事实表为准。 +`account_usage` 的 upsert **不在**上述事务内:它由调度器在槽位释放 / 用量观测时点独立写入(与事实写入不在同一代码时刻),每笔 upsert 自身原子。模型窗口分布统一从 `request_time_buckets` 查询,不再双写独立累计表。错误事件没有可信 `service_tier` 一等列,因此实时错误桶与 `rebuild-buckets` 都固定写 `__unknown__`;两条路径不得使用不同来源猜测 tier。 ### 5.3 Dashboard 口径决策(钉死) @@ -718,7 +693,7 @@ errors: 错误分布 / 排查 ← ops_error ## 6. SQLite v3 → PG 一次性搬迁 -引擎切换,不存在就地迁移:PG 库由 0001 基线全新建立,历史数据经**导入命令**搬入。旧文档的 0004 迁移草案(数据决策 1–4 与管理 API key 哈希回填)全部由导入命令吸收,SQLite 侧不再演进(v3 即其终版)。 +引擎切换,不存在就地迁移:PG 库由 0001 全新建立到 v1 终态,历史数据经**导入命令**搬入。旧文档的 0004 迁移草案(数据决策 1–4 与管理 API key 哈希回填)全部由导入命令吸收,SQLite 侧不再演进(v3 即其终版)。 ### 6.1 导入命令 @@ -726,7 +701,24 @@ errors: 错误分布 / 排查 ← ops_error codex-proxy-rs import-sqlite <旧库路径.sqlite> ``` -- 前置:目标 PG 为空库(仅 0001 基线,无业务行),否则拒绝执行——导入是一次性动作,不做增量合并。 +Docker Compose 部署应先停止旧 SQLite 实例并备份源库,确认目标 PG volume 为新建空卷, +再只启动 PG/Redis 并显式执行容器内二进制;不能先启动 `codex-proxy-rs` 服务,否则服务 +初始化的管理员与运行时设置会使目标库不再为空: + +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d postgres redis +docker compose --env-file deploy/.env -f deploy/docker-compose.yml run --rm --no-deps \ + -v "$(realpath /path/to/legacy.sqlite):/tmp/legacy.sqlite:ro" \ + codex-proxy-rs codex-proxy-rs import-sqlite /tmp/legacy.sqlite +docker compose --env-file deploy/.env -f deploy/docker-compose.yml run --rm --no-deps \ + codex-proxy-rs codex-proxy-rs rebuild-buckets +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d codex-proxy-rs +``` + +镜像入口为 `tini`,服务二进制位于镜像 `CMD`;因此 Compose `run` 覆盖命令时必须写成 +`codex-proxy-rs codex-proxy-rs import-sqlite ...`,第一个是服务名,第二个才是容器内二进制。 + +- 前置:目标 PG 已迁移到当前版本且无业务行,否则拒绝执行——导入是一次性动作,不做增量合并。 - 源库校验:`schema_migrations` 最高版本必须为 3,否则拒绝(未知谱系不猜)。 - 全程单个 PG 事务:要么全部导入,要么全部回滚。源库只读打开。 - Redis 不参与导入:v3 的运行态数据按 §6.3 显式丢弃。 @@ -740,7 +732,8 @@ codex-proxy-rs import-sqlite <旧库路径.sqlite> | `client_api_keys` | PG | `key`、`prefix` 原值搬迁;完整 key 继续作为管理端长期复制与 CCSwitch 导入的唯一事实 | | `runtime_settings` | PG | `admin_api_key` 明文 → 哈希落 `admin_api_key_hash`;三个 retention 列取默认值 | | `accounts` | PG | 布尔 0/1→boolean,时间戳→timestamptz;旧版曾落库的瞬态 `refreshing` → `expired` 并清空 `next_refresh_at`,防止迁移后自动消费 RT;其余业务状态原样 | -| `account_usage` / `account_model_usage` | PG 原样 | 同上 | +| `account_usage` | PG 原样 | 同上 | +| `account_model_usage` | **不导入** | 终态无生产读者,只计入丢弃报告 | | `account_cookies` | PG | `expires_at` 字符串解析为 timestamptz(RFC2822/RFC3339);解析失败 → NULL(降级为会话 Cookie)并计数报告 | | `fingerprints` / `fingerprint_update_history` | PG 原样 | JSON text→jsonb | | `usage_records`(v3 混合表) | **拆分**,见 §6.3 | — | @@ -758,6 +751,7 @@ codex-proxy-rs import-sqlite <旧库路径.sqlite> - 全部管理端会话(重新登录)、会话亲和(退化为重新调度)、刷新租约(自愈)、模型清单快照(按需重拉)——运行态按 §2.9 本就可丢弃; - v3 usage_records 中的噪音行(决策 3); +- v3 `account_model_usage` 行(终态模型窗口直接查询 `request_time_buckets`); - 旧版误落库的 `accounts.status='refreshing'` 不作为业务状态保留,按 §6.2 规范化为 `expired`; - 历史桶中错误路径污染的 token 按旧口径保留展示、不追溯清洗;事实保留期内的窗口随后由 `rebuild-buckets` 用新口径重算覆盖。 @@ -776,20 +770,21 @@ codex-proxy-rs import-sqlite <旧库路径.sqlite> ## 7. 代码改造清单(本次交付) -与导入命令同一 PR 交付,按依赖顺序。**2026-07-10 已逐项核验完成**,对应自动化验收由后端集成测试、前端构建、架构检查和 Compose 配置检查共同覆盖: +按依赖顺序交付。**2026-07-12 终态以本节与自动化测试共同约束**,验收由后端集成测试、前端构建、架构检查和 Compose 配置检查共同覆盖: -1. **连接层**(`infra/database.rs` + `infra/redis.rs`):PgPool(参数见 §1)+ 迁移框架(PG 谱系 0001 起);Redis ConnectionManager + 键前缀 + `PING` 健康检查。`Cargo.toml`:sqlx 加 `postgres`(保留 `sqlite` 仅供导入命令读源库),新增 `redis` crate。 +1. **连接层**(`infra/database.rs` + `infra/redis.rs`):PgPool(参数见 §1)+ 迁移框架(PG 谱系 0001 起,版本从末条迁移派生,严格顺序与 checksum 校验);Redis ConnectionManager + 键前缀 + `PING` 健康检查。`Cargo.toml`:sqlx 加 `postgres`(保留 `sqlite` 仅供导入命令读源库),新增 `redis` crate,删除未使用的 SQLx `uuid` feature。 2. **配置**(`bootstrap/config.rs`、`deploy/config.example.yaml`):`database.url` 改 `postgres://…`,新增 `redis.url`。 3. **鉴权归因**(`keys/{store,service,manage}.rs`、`api/client/auth.rs`):key 创建落唯一 `key` 列;鉴权按完整 key 做 PG unique 点查(删除进程内鉴权缓存);鉴权返回 key `id`,调用链把 `client_api_key_id` 装进请求上下文直至事件写入。管理端列表持续返回完整 key,前端长期保留复制与 CCSwitch 导入入口。 4. **事件结构**(`telemetry/{usage,ops}`、`telemetry/recorder.rs`、`dispatch/recording.rs`):成功/失败事件携带 `client_api_key_id` 与 `provider`;token、requested/upstream_model、service_tier、first_token_ms 从 metadata 移到一等字段;metadata 不再写已提升字段;UsageRecord 删除 `level`(成功事实无等级)。 5. **写入路径**(`telemetry/usage/store.rs`、`telemetry/ops/store.rs`、`telemetry/buckets/store.rs`):§5.2 的两条 PG 事务;错误路径对桶只 `error_count + 1`;`__unknown__` sentinel;min/max 延迟使用 nullable-safe 的 `least()`/`greatest()`;内联 trim 移除。 6. **查询路径**(`telemetry/{usage,ops,account_usage}`、`api/admin/{usage,ops,dashboard,accounts}_routes*`):summary/分布/趋势全部走列,删除所有 `->>` 聚合与 metadata LIKE;新增 `/api/admin/ops/errors` 错误明细查询面;Dashboard 按 §5.3 只改取数来源,不改既有文案。 -7. **运行态改 Redis**(`auth/store.rs` 会话、`accounts/refresh/lease.rs`、`dispatch/affinity/store.rs`、`models/store.rs`):按 §4B 契约实现;删除 admin 会话/亲和清理任务与亲和重启恢复;账号删除路径挂 Redis 亲和级联(§4B.3)。 -8. **维护命令**(`main.rs` CLI):`import-sqlite `(§6);`rebuild-buckets`——删除事实保留期内的桶并从两张事实表重算(15 分钟槽对齐在 SQL 侧 `floor(epoch/900)*900`,§2.3);保留期外只读不动。范围明确**不含** `account_usage` / `account_model_usage`(§2.10,不可重建)。 -9. **清理任务**(`bootstrap/tasks/`):三张时间增长表的周期 trim(读 `runtime_settings` 三列),`account_model_usage` 每账号 100 行与 `fingerprint_update_history` 全局 100 行硬上限;cookie 清理保留;admin 会话与亲和清理任务删除(TTL 接管)。 -10. **健康检查**(`api/router.rs`):PG `select 1` + Redis `PING`,任一失败即 503。 -11. **测试**(`backend/tests/`):support 换 PG(每测试独立数据库,`CPR_TEST_DATABASE_URL` 指定服务端)+ Redis(每测试随机键前缀,`CPR_TEST_REDIS_URL`);storage 测试断言终态 DDL、导入拆分规则(成功行迁入 / error 行转 ops / 噪音行丢弃 / 旧 `refreshing` 规范化)、两条写入事务的原子性、0ms 延迟样本可入桶、租约原子性。 -12. **部署**(`deploy/docker-compose.yml`):新增 postgres、redis 服务与健康检查依赖。 +7. **运行态改 Redis**(`auth/store.rs` 会话、`accounts/refresh/lease.rs`、`dispatch/affinity/store.rs`、`models/store.rs`):按 §4B 契约实现;删除 admin 会话/亲和清理任务与亲和重启恢复;账号删除路径挂 Redis 亲和批量级联;模型 HASH 全量替换;持久化 `identity_hmac_secret`。 +8. **账号导入**(`fleet/manage/import.rs` + `fleet/store/write.rs`):新账号身份/凭据、`next_refresh_at` 与 quota 状态由单条原子 SQL 一次提交;已有账号更新与刷新时间同样原子,任何字段校验或数据库错误都不得留下半初始化行。 +9. **维护命令**(`main.rs` CLI):`import-sqlite `(§6);`rebuild-buckets`——删除事实保留期内的桶并从两张事实表重算(15 分钟槽对齐在 SQL 侧 `floor(epoch/900)*900`,§2.3);保留期外只读不动。范围明确**不含** `account_usage`(§2.10,不可重建)。 +10. **清理任务**(`bootstrap/tasks/`):三张时间增长表的周期 trim(读 `runtime_settings` 三列),`fingerprint_update_history` 全局 100 行硬上限;cookie 清理保留;admin 会话与亲和清理任务删除(TTL 接管)。 +11. **健康检查**(`api/router.rs`):PG `select 1` + Redis `PING`,任一失败即 503。 +12. **测试**(`backend/tests/`):support 使用每测试独立 PG 数据库与随机 Redis 前缀,并在测试进程前缀级清理残留;覆盖 v1 终态 schema 与 checksum 防篡改、导入原子性、回放容量/TTL/账号删除/敏感字段、账号池与 WebSocket pool 并发不变量、存储失败降级以及保留期任务。第一条真实增量迁移上线时再增加对应的 v1→v2 演进测试,不得为尚未发布的基线制造伪历史。 +13. **部署**(`deploy/docker-compose.yml`):新增 postgres、redis 服务与健康检查依赖,PG/Redis 数据卷及 `/app/data` 身份密钥目录跨镜像持久化。 --- @@ -801,12 +796,12 @@ codex-proxy-rs import-sqlite <旧库路径.sqlite> | --- | --- | | 多管理员 | `admin_users` 加 `username`/`role` + PG `admin_login_events` + Redis `cpr:admin:user_sessions:` SET(踢会话索引) | | 按 key 限流/配额 | 新表 `client_api_key_policies (key_id FK, …)`;归因数据已在事实表就位;运行态计数走 Redis | -| 错误运维大盘 | `ops_error_logs` 按 §4.9 预留清单增列 | +| 错误运维大盘 | `ops_error_logs` 按 §4.8 预留清单增列 | | 长期趋势/年度账务 | 新表 `request_day_buckets`(由 15 分钟桶降采样归档),Dashboard 才允许出现"历史总量" | | 刷新历史分析 | 新表 `account_refresh_events` | | 模型别名审计 | `model_aliases_json` 拆 `model_aliases` 表 | | 凭据静态加密 / 合规 | `accounts` 拆 `account_credentials`(1:1,PK=FK),集中一个破坏窗口 | -| 接入第二上游 provider(已规划:Cloudflare Workers AI) | 事实表与桶的 `provider` 维度已就位(§4.8–4.10),CF 首条请求落库前无需再动事实表。届时一个迁移完成:`accounts` 加 `provider` + `chatgpt_account_id/user_id` 更名 `upstream_account_id/upstream_user_id` + 唯一索引加 provider 维度;模型缓存 HASH field 改 `:`;billing 单价查找加 provider 参数。回填全部确定为 `'openai'` | +| 接入第二上游 provider(已规划:Cloudflare Workers AI) | 事实表与桶的 `provider` 维度已就位(§4.7–4.9),CF 首条请求落库前无需再动事实表。届时一个迁移完成:`accounts` 加 `provider` + `chatgpt_account_id/user_id` 更名 `upstream_account_id/upstream_user_id` + 唯一索引加 provider 维度;模型缓存 HASH field 改 `:`;billing 单价查找加 provider 参数。回填全部确定为 `'openai'` | Cloudflare 账号对现有 schema 的适配(无需新列):静态 API key = `access_token`,`refresh_token`/`access_token_expires_at` NULL(永不过期,语义已覆盖);无套餐配额窗口 → `account_usage.window_*` 恒为初始值、窗口边界 NULL;`account_cookies` 对 CF 账号空置。**硬规则**:任何新 provider 的第一条请求落库之前,其事实行必须已携带 provider 归因——终态之后这条自动满足。 diff --git a/docs/dead-code-and-smells-review.md b/docs/dead-code-and-smells-review.md new file mode 100644 index 000000000..ad38381fc --- /dev/null +++ b/docs/dead-code-and-smells-review.md @@ -0,0 +1,60 @@ +# 死代码与异味审查(backend) + +> 审查于 `feat/postgres-redis-migration` 工作树(fleet 改名、dispatch 重排之后)。方法:①编译器 `dead_code` lint——CI 跑 `clippy -D warnings`,所以**私有项与 `pub(crate)` 项的死代码必然为 0**(否则 CI 挂),死代码只可能藏在裸 `pub` 且 crate 内无调用者的项里;②对全部 441 个 `pub fn` 做"全局引用计数=1(仅定义)"筛查;③codegraph 逐个验证候选的真实调用者(排除 trait/宏/route/serde 间接);④`clippy` 加 `nursery`/`cognitive_complexity`/`too_many_lines`/`significant_drop` 等非默认 lint 扫异味。 + +先说结论:**这是我审过纪律最干净的一处**——零 `#[allow(dead_code)]`、零 `#[allow(unused)]`、零 TODO/FIXME/HACK、零注释掉的代码块、零可疑残留命名(legacy/deprecated/old/v1)、最大文件 873 行(无失控巨型文件)。大重构没有留下常见的残骸。真正的死代码只有 **4 个**,且全是同一模式——"纯委托 wrapper,预留了但从未被采用";异味也很少,值得一提的是 **2 处锁持有过久**(与 [concurrency-review](concurrency-review.md) 呼应)。没有 P0,没有架构性坏味道。 + +## P1 — 死代码(4 个,建议删) + +四个都是裸 `pub` 函数、crate 内(含 tests/)**零调用者**、函数体只有一行委托给另一个真正在用的函数。共同性质:**它们是"便利封装"或"变体",但调用方都直接用了被委托的原函数,wrapper 反而没人碰**。均属 binary crate 的 lib pub 项、无外部消费者,删除不影响编译与运行时行为。 + +1. **`openai_sse_frame`** —— `api/client/sse.rs:42`。函数体 `encode_sse_event(event, data)`,零引用;同文件的 `done_sse_frame` / `event_stream_response` / `SseResponseOptions` 都在用,只有这一个死。调用方全都直接用 `encode_sse_event`。 +2. **`sse_frame_separator`** —— `upstream/openai/protocol/sse.rs:67`。函数体 `sse_frame_separator_bytes(input.as_bytes())`,零引用;真正被用的是它内部委托的 `sse_frame_separator_bytes`(2 callers)和兄弟 `sse_frame_end`(:73,活)。这个 `&str` 版从没被调。 +3. **`parse_optional_rfc3339_utc`** —— `infra/time.rs:21`。`parse_rfc3339_utc` 的 `Option` 版(`value.map(...).transpose()`),零引用;需要解析可选时间的地方都自己 `map` 了。 +4. **`AccountScheduler::set_weights`** —— `fleet/scheduler/mod.rs:101`。注释写"策略切换或热更时调用",但实际热更走的是**重建整个 `AccountScheduler`**(权重在 `new` 时注入),这个 setter 从没被调——是 `AccountScheduler` 唯一零引用的方法(兄弟 `reset_cursor`/`report_feedback`/`forget_feedback`/`clear_feedback` 都有引用)。删除时连同 `&mut self` 的可变借用需求一起消失,是净收益。 + +> 顺带:`api/client/sse.rs` 曾被怀疑是 Chat 端点删除后的整体遗留,核实后**不是**——除 `openai_sse_frame` 外其余四项均有 responses 端点在用。只死了一个函数,不是一个文件。 + +## P1 — 锁持有过久(与并发审查呼应) + +clippy 的 `significant_drop` 系列精确命中了 [concurrency-review](concurrency-review.md) 关注的锁路径: + +5. **`auth/service.rs:134`** —— `significant_drop_tightening`:登录失败计数的锁 guard 在临界区外仍被持有,可提前 drop。正是并发审查 P2 里"登录限流 check/record 分离"那处锁,收紧持有范围顺带缩小 TOCTOU 窗口。 +6. **`fleet/refresh/service.rs:477`** —— `significant_drop_in_scrutinee`:`if let ... = <持锁表达式>` 的 scrutinee 里产生的锁临时量,会在整个 `if let` 块结束才 drop——锁被 if 分支体的时长白白拖长。改成先绑定、取值、显式 drop 再判断即可。 + +## P2 — 轻度异味(视精力顺手清) + +7. **31 个"仅测试用"的 pub 构造器** —— `upstream/openai/protocol/websocket_errors.rs` 里 `websocket_*_invalid_required_fields` / `websocket_*_missing_*` 共 31 个 `pub fn`,构造畸形 WS 事件供测试断言用,**只被 tests/ 引用、生产路径零调用**。它们是"测试脚手架泄漏成生产 pub API"——理想应 `#[cfg(test)]` gate 或下沉到 `tests/fixtures`。同类还有 `discarded_rows`/`imported_rows`/`normalized_rows`(import 报告 getter)、`with_request_spacing`/`with_retry_delays`(builder)、`set_service_tier` 等。注意:这些**不是死代码**(有测试引用),是可见性/放置问题;而且部分(如 import 报告 getter)作为公共结构的访问器是合理的,不必一刀切。建议至少把 websocket_errors 那 31 个 gate 起来。 +8. **零散 clippy nursery 项**(逐个都是几行的小修): + - `upstream/openai/transport/websocket_pump.rs:127` —— `needless_pass_by_ref_mut`:参数是 `&mut` 但从未可变使用,签名过度要求可变借用。 + - `dispatch/recovery/implicit_resume.rs:22` —— `derive_partial_eq_without_eq`:derive 了 `PartialEq` 可顺带 `Eq`。 + - `fleet/manage/lifecycle.rs:435` —— `suboptimal_flops`:`a * b + c` 可用 `mul_add` 提精度/性能(微优化)。 + - `api/client/responses.rs:257` —— `single_option_map`:函数只是 `map` 一下参数,可内联。 + - `api/admin/usage_routes.rs:578` —— `use_self`:结构名重复,可用 `Self`。 + - `api/admin/accounts_routes/export_routes.rs:4` —— `redundant_pub_crate`:private 模块内的 `pub(crate)` 冗余。 + +## 做得好的(记录一下,免得反复纠结) + +- **零显式压制**:全仓无 `#[allow(dead_code)]` / `#[allow(unused)]`,唯一的 `#[allow(clippy::too_many_arguments)]` 和唯一的 `#[expect(...)]`(trace.rs 的 tower 回调按值传参)都是正当的。团队没有靠 allow 掩盖问题。 +- **零技术债标记**:零 TODO/FIXME/HACK/XXX、零注释掉的代码块。 +- **私有死代码被 CI 清零**:`-D warnings` 让编译器的 `dead_code` lint 成为硬门禁,私有与 `pub(crate)` 的未使用项无法进主干。这是死代码只剩 4 个的根本原因。 +- **文件规模健康**:最大 873 行(`fleet/pool/mod.rs`),前 15 大文件都在 650–873 之间,没有几千行的上帝文件;`too_many_lines`/`cognitive_complexity` 在生产代码里近乎零命中。 +- **无重复能力/无垃圾桶模块**:无 `utils`/`helper`/`misc`,纯 re-export 的 `mod.rs`(scheduler/strategy、quota、api…)都是正常的模块聚合,不是空壳。 + +## 核实过的误报 + +- **"441 个 pub fn 里死代码应该不少"** —— 不成立。逐个验证后只有 4 个真死;绝大多数 pub 项经 codegraph 确认有调用者,或是 axum route handler(被 `get()`/`post()` 注册)、serde 派生目标、trait 方法、`main`/bootstrap 入口。裸 pub 数量大 ≠ 死代码多。 +- **"仅测试用的 pub = 死代码"** —— 不是。它们被 tests/ 引用,编译器和引用计数都能看到;归类为可见性/放置异味(P2-7),不是死代码。 +- **238 条 clippy warning 听起来很多** —— 其中约 24 条是 `nursery` 元标签、大量是 `missing_const_for_fn` 一类风格项(已在扫描时 `-A` 掉主要噪音),真正值得动的生产代码异味就是 P1-2 的两处锁 + P2-8 的六个小项。默认 lint 集(CI 跑的)本就是 0。 + +## 方法局限(诚实说明) + +- "全局引用计数=1"筛查对**通用函数名**(如 `new`/`from`/`build`/`default`)会漏报——同名符号在别处巧合出现会互相"证明存在"。所以本报告的死代码清单是**下界**,可能还有极少数通用名死函数未被这套启发式捕获;但 codegraph 对候选的验证是准的,列出的 4 个确定为死。 +- 未跑 `cargo-udeps`(需 nightly)——unused 依赖另见 [dependency-review](dependency-review.md)(已用 cargo-machete 覆盖)。 + +## 建议动手顺序 + +1. **P1 死代码 4 个** —— 一个 commit 删掉,零风险(零调用者),`cargo build` 即验证。顺带 `set_weights` 删除后 `AccountScheduler` 少一个 `&mut` 方法。 +2. **P1 两处锁**(auth/service.rs:134、refresh/service.rs:477)—— 与 [concurrency-review](concurrency-review.md) 的锁修复合并到同一个 PR 更合适。 +3. **P2-7 的 31 个测试构造器** gate 进 `#[cfg(test)]` 或下沉 fixtures —— 机械但面稍大,单独一个 commit。 +4. **P2-8 零散项** —— 顺手 `cargo clippy --fix` 能自动改掉大部分,人工确认即可。 diff --git a/docs/frontend-review.md b/docs/frontend-review.md new file mode 100644 index 000000000..29c930a62 --- /dev/null +++ b/docs/frontend-review.md @@ -0,0 +1,104 @@ +# 前端工程审计(frontend) + +> 审查于 `feat/postgres-redis-migration` 工作树。对象:`frontend/`(Vue 3.5 + TypeScript 6 + Vite 8/rolldown + Tailwind 4 + Pinia 3,管理台 SPA)。规模实测:73 个 `.vue` + 46 个 `.ts` 共 119 文件、15,643 行。文中路径均相对 `frontend/`;统计类结论(any 数量、文件行数、引用计数)均由 `rg`/`wc` 实测,`vue-tsc -b --force` 实际跑过(exit 0)。 + +先说结论:底子好于预期。TS strict 全家桶开启且 typecheck 进了 CI、目录分层与命名纪律好、全项目唯一一处 `v-html` 过了 DOMPurify、echarts 正确 dispose、Pinia 用得克制。真正的债集中在三件事:**工具链只有 prettier 没有 ESLint(也没有测试)**、**类型系统在 API 边界断层(146 处显式 `: any`,域实体零 interface,strict 形同虚设地向视图层泄漏 any)**、**401 会话过期前端完全没处理(拦截器里那句 "redirecting to login" 是假的)**。三者都可控,不需要重写。 + +## P0 — 工程门禁与真 bug + +### 1. 无 ESLint:唯一的静态门禁是 prettier +- 现状:`package.json` scripts 只有 `dev/build/preview/format/format:check`,devDependencies 无任何 eslint 包,全仓无 eslint 配置文件;CI(`.github/workflows/ci.yml:90`)只跑 `format:check` + `build`。 +- 问题:格式化不是 lint。当前代码里 lint 本可拦下的东西都在裸奔:146 处 `: any`、10 处 `console.*`、21 处 `catch (err: any)`、未 await 的 promise 无人检查。团队/AI 协作写入时没有任何质量回压。 +- 建议:补 flat config——`eslint` + `eslint-plugin-vue` + `typescript-eslint` + `eslint-config-prettier`。规则重点:`vue/recommended`、`@typescript-eslint/no-explicit-any`(先 warn)、`no-console`(allow `warn`/`error`)、type-checked 模式下的 `no-floating-promises`。加 `lint` script 并进 CI。一次性设施,收益长期。 + +### 2. API 层类型断层:strict 全开,但域实体零 interface +- 现状:`src/api/modules/` 7 个模块中只有 `system.ts` 定义了类型并用 `request` 传参;其余全部 `params?: any` / `data: ApiPayload` + 隐式 `Promise`(`request.ts:103` 的默认泛型是 ``)。`usage.ts` 8 个函数全 `any`;`useDashboard.ts:22` 直接 `export function useDashboard(): any` 且内部 10 个 `ref`;Account/ApiKey/UsageRecord/DashboardSummary 这些核心实体在前端**不存在任何 interface**。实测:`: any` 146 处、`as any` 4 处、`ref` 处收口。优先 usage 与 accounts(any 最密)。手写可行;更稳的路线是从后端类型生成(ts-rs / schemars→OpenAPI→openapi-typescript),因为 Rust 侧类型是现成的。 + +### 3. 401 会话过期无任何前端处理,那条日志在撒谎 +- 现状:`src/api/request.ts:74` 对 401 只有 `console.warn('Unauthorized, redirecting to login...')`——**没有任何 redirect 代码**。路由守卫(`src/router/index.ts:25`)只在导航时校验,且 `sessionChecked` 置位后不再重查(`src/stores/modules/auth.ts:21`)。 +- 问题:会话 cookie 过期后,用户停留的页面上所有请求持续 401 失败,各处 toast 报"请求失败",永远不会回到登录页;刷新才会触发守卫。对管理台这是高频真实路径(挂着 dashboard 过夜)。 +- 建议:响应拦截器里 401 → 重置 auth store + `router.replace('/login')`(排除 login/auth-status 自身的 401,防循环;注意 request.ts 与 router 的循环 import,可用动态 import 或事件解耦)。十几行改动,收益最大。 + +## P1 — 结构与一致性 + +### 4. 零测试 +- 现状:全 frontend 无 vitest/@vue/test-utils/任何 `*.spec.*`;CI 无前端测试步骤。 +- 问题:管理台低覆盖可以接受,但连纯函数都没有测试就亏了——`views/usage/constants.ts` 的 `formatCompactTokenCount`(K/M 边界、toFixed 位数)、`usageModelDisplay`(primary/secondary 优先级瀑布)是典型的"便宜且值得"的单测对象,现在这类逻辑只能靠 UI 手点回归。 +- 建议:vitest 起步,先只测 `src/utils/`、`views/*/constants.ts` 的纯函数与 `useIdSet`/`useAsyncAction`,不碰组件渲染测试。 + +### 5. `views/usage/constants.ts`(379 行)名不副实 +- 现状:名为 constants,实际是 2 个列定义 + 20 余个取值/格式化/分类**函数**(`tokenTotal`、`usageRecordType`、`visibleRequestText`、`extractInputText`……),且参数全 `record: any`。 +- 问题:命名与内容不符,复用者找不到;`docs/architecture.md:337` 对 backend `utils/` 已有"按角色词表命名、禁堆泛用文件"的明文规范,前端同理。它也是 any 密度最高的单文件(20 处)。 +- 建议:拆 `formatters.ts` + `record-helpers.ts`(或并入 composable);配合 P0-2 定义 `UsageRecord` 后,这里的 any 自然消解。 + +### 6. 巨型 composable / 组件已到拆分临界 +- 现状(top 5 实测):`layout/components/SystemUpdateModal.vue` 600 行、`views/dashboard/composables/useDashboard.ts` 447 行、`views/accounts/composables/useAccountMutations.ts` 442 行、`components/base/BaseScrollbar.vue` 438 行、`views/accounts/composables/useAccountConnectionTest.ts` 424 行。 +- 问题:`useDashboard` 把状态、加载、竞态控制、格式化、图标映射全揉进一个函数并返回 `any`;`useAccountMutations` 装了 9 个 action + 3 个 modal 开关 + 表单状态。尚未失控,但每加一个功能都在加重。 +- 建议:不必立刻大拆。`useDashboard` 先把 `metricCards`/formatter 等纯函数移出 composable 体;`useAccountMutations` 可按 create/delete/refresh 分组。列在此处主要是"别再往里加"。 + +### 7. 同名 composable 不同语义:filters 双轨 +- 现状:`views/accounts/composables/useAccountFilters.ts` 是 server-side 分页(`bindAccountLoader` 注入加载回调,搜索 debounce 后重新请求);`views/api-keys/composables/useApiKeyFilters.ts` 是 client-side 全量过滤 + 内存分页(`api-keys.ts:5` 的 `getApiKeys()` 无分页参数,一次拉全)。 +- 问题:两个 `useXxxFilters` 名字一致、心智模型完全不同;`bindAccountLoader` 的 setter 注入让 composable 之间有隐式调用顺序耦合(loader 未 bind 时静默不加载)。api-keys 数据量小,全量拉取本身没错,错在同名掩盖了差异。 +- 建议:低成本方案是命名区分(如 `useAccountServerFilters` / 注释标明 client-side);或统一为传入 loader 参数而非事后 bind。 + +### 8. `ApiError` 定义了却没导出 +- 现状:`src/api/request.ts:25` 的 `ApiError` 带 `status/code/requestId`,但未 `export`;`src/composables/useSystemUpdate.ts:267` 只能 `(error as { status?: unknown }).status` 鸭子类型取值。 +- 问题:调用方无法 `instanceof ApiError` 收窄,好不容易带上的 `x-request-id`(与后端 request_id 链路呼应)在 UI 层取不到类型化的值。 +- 建议:`export class ApiError`,useSystemUpdate 改用 instanceof;错误 toast 可顺带展示 requestId 方便对账后端日志。 + +### 9. 双轨错误处理:useAsyncAction(toast)与裸 console.error 并存 +- 现状:`src/composables/useAsyncAction.ts` 是正确的统一通道(loading 互斥 + toast + 可选 rethrow),accounts/api-keys 的 mutation 大多走它;但 `useDashboard.ts:53,67,84`、`useAccountMutations.ts:320,373` 仍是 `try { … } catch { console.error }`,`request.ts:55-83` 的拦截器里还有 5 处按状态码打 console。 +- 问题:dashboard 加载失败用户无感知(只进 console);拦截器里的 console 与 reject 后调用方的处理重复,信息价值低。 +- 建议:dashboard 的加载失败至少给一次 toast 或页面 error 态;拦截器的 switch/console 块可整体删除(ApiError 已带全部信息)。引入 ESLint `no-console` 后这些会自然浮出。 + +## P2 — 打磨项,视精力而定 + +### 10. 死代码:`BaseSwitch.vue` +- `src/components/base/BaseSwitch.vue`(60 行)全仓 0 引用(PascalCase 与 kebab-case 都查过)。删除即可。其余低引用 base 组件核实过都有真实使用(见"核实过的误报")。 + +### 11. 路由无懒加载,登录页也进主包 +- `src/router/routes.ts` 全部静态 import,产物主 chunk 599 KB(未压缩;echarts 已手动拆出 524 KB + zrender,`vite.config.ts` 的 codeSplitting 配置是对的)。内网管理台可接受;要做的话把 5 个 view 改 `component: () => import(...)` 即可,登录页首屏收益最明显。 + +### 12. GET 请求 `_t=Date.now()` 缓存穿透 +- `src/api/request.ts:45-50` 给所有 GET 挂时间戳查询参数。老派 hack,污染 URL 且对 HTTP 缓存语义是绕过而非声明。正统做法是后端 admin API 返回 `Cache-Control: no-store`(代理后端本就该如此),前端删掉这段。 + +### 13. i18n 缺失——记为决策而非欠债 +- 49/73 个 `.vue` 硬编码中文文案,无 vue-i18n。单语管理台完全合理,不建议现在引 i18n;仅提示:若未来有开源/多语诉求,补收成本随文件数线性增长,届时是一次机械但大面积的改造。 + +### 14. a11y 基本盘尚可,modal 缺 focus 管理 +- 42 处 `aria-*`、`BaseModal.vue:82` 有 `aria-modal="true"`,自研组件有基本意识;但 BaseModal 未见 focus trap/焦点归还实现(未运行时验证)。管理台优先级低,做 base 组件迭代时顺手补。 + +### 15. 小件 +- `// @env browser` 非标准注释头出现 5 处(`src/composables/useDownload.ts:1` 等),无任何工具消费它;引入 ESLint 后删除或换成真实配置。 +- `src/api/index.ts` 桶文件 `export *` 全量再导出:目前无命名冲突,但两个模块一旦撞名会静默覆盖;模块数少,维持现状可接受,新增模块时留意。 + +## 做得好的(记录一下,免得反复怀疑) + +- **TS 严格度配置满分**:`tsconfig.app.json` 开了 `strict` + `noUnusedLocals` + `noUnusedParameters` + `verbatimModuleSyntax`,`build` 脚本前置 `vue-tsc -b` 且 CI 必跑——typecheck 是硬门禁(实测通过)。问题在边界的显式 any(P0-2),不在配置。 +- **分层与命名纪律**:`api/modules` 按域一文件、`views/<域>/{components,composables,constants.ts}` feature-first、通用件收敛在 `components/base`(16 个);组件 PascalCase、composable `useXxx`、view 入口 `index.vue`,抽查未发现违例。`src/utils/` 仅 3 个文件且各司其职(async/date/markdown),没有垃圾桶化——`docs/architecture.md` §6.4 对 utils 的告诫在前端是兑现的。 +- **XSS 防线**:全项目 `v-html` 仅 1 处(`SystemUpdateModal.vue:387`),源头 `src/utils/markdown.ts` 走 `DOMPurify.sanitize`(ADD_ATTR 白名单),外链统一注入 `rel="noreferrer"`。 +- **资源与竞态**:`components/charts/BaseChart.vue` 在 `onBeforeUnmount` 正确 dispose echarts 实例;`useDashboard` 用 `trendRequestId` 防过期响应回写;`useIdSet.run()` 天然防并发重复提交。 +- **useAsyncAction 抽象正确**:loading 互斥 + `withMinimumDuration` 防闪烁 + toast 错误统一 + 可选 rethrow,是该有的形状,问题只是覆盖不全(P1-9)。 +- **Pinia 用得克制**:全局只有 auth/ui 两个 store,列表/表单状态留在 view-local composables——服务端状态没有被错误地塞进全局单例,三个列表视图状态互不重叠。 +- **ui store 主题切换工程质量高**:View Transition API circle reveal + 不支持时 fallback + `prefers-reduced-motion` 降级 + timeout/finally 双保险清理;`tokens.css` 235 个 `--cp-*` 变量统一光/暗主题,71 个组件用 Tailwind utility,仅 8 个 scoped style 块,样式组织一致。 +- **构建与供应链卫生**:`dist/` 未进 git;echarts/zrender 手动分包;`pnpm-workspace.yaml` 配了 `minimumReleaseAge` 供应链防护;依赖版本新且锁定。 +- **ApiError 携带 `x-request-id`**:与后端 request_id 链路闭环呼应(导出后更完整,见 P1-8)。 + +## 核实过的误报 + +- **"strict 没开、any 是隐式的"**——不成立。strict 全开、`vue-tsc` 通过,146 处 any 全是显式标注。这改变的是修法(补类型定义,而非改编译器配置),不改变问题本身。 +- **低引用 base 组件是死代码**——除 `BaseSwitch`(0 引用)外不成立:BaseTextarea(1 处)、BaseCheckbox(2 处)等均有真实调用点,是"备件少用"不是"没人用"。 +- **"stores 只有 2 个 = 状态管理缺失/该集中"**——不成立。列表页状态本就该 view-local,实查无跨视图重复状态;往 Pinia 里搬反而制造共享可变状态。 +- **`_t` 时间戳导致行为错误**——不成立,行为正确(配合 `withCredentials` 的 admin GET 总是新鲜数据),只是手段不优雅,降级列 P2-12。 +- **手写 UI 组件库是重复造轮子失控**——不成立。16 个 base 组件体量可控(最大 BaseScrollbar 438 行),与 tokens.css 主题体系一体;引入 Element Plus 反而带来 bundle 与主题适配成本。 + +## 建议动手顺序 + +1. **P0-3(401 跳登录)**先修:改动最小(request.ts 十几行)、用户可感收益最大。 +2. **P0-1(ESLint + CI)**一次性设施:规则先 warn 后 error 渐进,避免一次性 146 个 any 报错淹死。 +3. **P0-2 + P1-5 结对做**:先定义 `UsageRecord`/`Account`/`ApiKey`/`DashboardSummary` interface(usage 最密集,优先),顺手把 `usage/constants.ts` 拆掉——any 总量预计能砍半以上,`useDashboard(): any` 一并消解。 +4. **零风险小件随任意 PR 带走**:export ApiError(P1-8)、删 BaseSwitch(P2-10)、删 `@env browser` 注释与拦截器 console 块(P2-15、P1-9 前半)。 +5. **P1-4(vitest)**在类型补齐后做,纯函数先行。 +6. 其余 P1/P2 按精力。 diff --git a/docs/previous-response-not-found-fix.md b/docs/previous-response-not-found-fix.md index 0e69c159d..6d7b12c72 100644 --- a/docs/previous-response-not-found-fix.md +++ b/docs/previous-response-not-found-fix.md @@ -21,12 +21,12 @@ 1. 在 WebSocket 连接对象自身记录该连接当前可以续接的最新 response ID。 2. 发送 store=false + previous_response_id 前,先验证当前取出的池连接是否仍持有这个 ID。 -3. 只有代理自己做过隐式续接、手里保留完整输入快照时,才允许清除旧 ID 并透明重放;客户端显式传入但代理没有完整历史时,返回明确错误。 +3. 只有该 previous ID 能在 Redis 中命中代理管理且可完整重建的 replay 增量链时,才允许清除旧 ID 并透明重放;客户端显式传入但代理没有完整历史时,只在一个账号上原样尝试,失败后保留上游原始 400。 流式预取不能对所有请求统一等待真实输出。应根据请求是否具备安全重放能力选择提交边界: -- 普通请求、显式 previous_response_id:保持原有低延迟提交行为。 -- 代理生成的隐式续接:在首个不可逆输出或终态前暂存结构帧,以便失败时安全重放。 +- 普通新请求:首个可转发事件即可提交,不等待首个真实文本输出。 +- 任何 previous_response_id:仅在首个不可逆业务输出或终态前暂存结构帧。managed history 可在此边界内完整重放;external unknown 只用该边界保留原始错误形状,不做账号 fanout。 这个设计比“连接代次写入 Redis”更直接。OpenAI 的缓存语义是“一条具体 WebSocket 上最近的 response”,因此最准确的真相源就是池中的连接对象,而不是账号亲和表、模型名或持久化数据库。 @@ -321,22 +321,25 @@ ConnectionBusy 不表示历史已经丢失,应单独返回可重试错误: enum HistorySource { None, - Explicit, - Implicit, + Managed, + ExternalUnknown, } struct HistoryRecoveryPlan { source: HistorySource, - replay: Option, + managed_entry: Option, + replay_input: Option>, + external_attempted_account_id: Option, + replay_owner_account: bool, } 规则: -- 客户端原请求带 previous_response_id:source=Explicit,replay=None。 -- 代理根据完整输入自行应用 implicit resume:source=Implicit,replay=Some(snapshot)。 -- 没有 history:source=None,replay=None。 +- previous_response_id 命中 `affinity:v2:resp:*` 且增量父链完整:source=Managed,replay_input 为重建后的完整历史。 +- previous_response_id 未命中:source=ExternalUnknown,最多在首个调度账号原样尝试,不把未知 ID 广播到其余账号。 +- 没有 history:source=None,replay_input=None。 -take_replay 消耗 Option,天然保证最多恢复一次。 +managed history 先尝试所有权账号的原 continuation;失败后在同账号做一次 full replay,账号级失败再按当前设置选择的调度策略继续剩余候选。每个候选都从不可变原请求和同一份重建历史构造新请求。 ### 9.5 StreamCommitPolicy @@ -349,13 +352,13 @@ take_replay 消耗 Option,天然保证最多恢复一次。 选择规则: - recovery_plan.replay.is_some() + request.previous_response_id.is_some() -> UntilOutputOrTerminal - 其他 + 普通新请求 -> FirstForwardableContent -恢复后完整输入请求已经不带 previous ID,下一次尝试必须回到 FirstForwardableContent。 +full replay 已经不带 previous ID,但仍保持 `UntilOutputOrTerminal`,这样账号级错误可在真实输出提交前继续剩余候选;不存在固定毫秒 grace window,也不等待首个文本 token 才认定连接有活动。 ## 10. 连接池发送前验证 @@ -487,59 +490,38 @@ invalid_encrypted_content 和 tool output 缺失属于不同失败种类,可 ## 14. dispatch 安全恢复 -### 14.1 调整快照捕获时机 +### 14.1 completed 后保存有界增量 -目标基线 apply_implicit_resume 在完成 affinity 查询前就 clone 完整 input。 - -将 ImplicitResumeSnapshot::capture(request) 移到所有资格检查通过之后、第一次修改 request 之前: - - 1. 找 continuation_start。 - 2. 查 conversation、variant、instructions、function call、account。 - 3. 构造 replay input。 - 4. 此时 capture 原始完整请求。 - 5. 再 set_previous_response_id、set_input、修改 transport。 - -这样未命中隐式续接时不会无意义 clone 大输入。 +每个成功的 `response.completed` 只保存本轮客户端 input、本轮上游 output、父 response ID、depth 和累计字节数。写入前递归删除 `encrypted_content` 和顶层上游对象 ID,保留 `call_id` 等协议关联字段。单节点 2 MiB、单会话 16 MiB、最大 128 层、TTL 4 小时;超限不写 replay,但不修改或截断正常响应。 ### 14.2 替换 recover_request_history 删除“无快照也 strip history”的行为。 -新函数语义: - - async fn try_replay_full_request( - request, - recovery_plan, - account_id, - evict_reasoning_replay, - ) -> bool +新函数语义由 `HistoryRecoveryPlan::prepare_attempt` 与 `recover_managed_history` 共同表达: 流程: -1. recovery_plan.take_replay()。 -2. 没有 snapshot,返回 false,不修改请求。 -3. 必要时清除 reasoning replay。 -4. 忘记失效 response ID 的 session affinity。 -5. snapshot.restore(request)。 -6. 清除 previous_response_id 和 turn_state。 -7. 返回 true。 +1. managed 所有权账号第一次尝试保留 previous ID,并绑定记录的 conversation/turn state。 +2. 收到精确 history error、连接丢失或 continuation 不可用时,确认 replay 父链完整。 +3. 从增量链重建历史,追加本轮输入,清除 previous ID、turn state 和账号绑定加密内容。 +4. 在所有权账号新连接完整重放一次;账号级失败时为当前调度策略给出的下一候选重新构造干净请求。 +5. 父链缺失、超限或损坏时返回 false,不把残缺输入伪装成新会话。 只有返回 true 才能 continue 调度循环。 ### 14.3 显式 previous ID -客户端显式 previous ID 失败时: +客户端显式 previous ID 分两类: -- 不删除 ID 后重试。 -- 不切换账号后用残缺输入重试。 -- 释放账号 lease。 -- 返回 ResponseDispatchError::HistoryUnavailable。 +- managed:代理已保存完整增量链,可以按 §14.2 透明恢复和换号。 +- external unknown:不删除 ID、不切换账号后发送残缺输入、不广播到账号池;释放 lease 并原样返回上游 status/type/code/message/param/body。 非流式响应: HTTP 400 type = invalid_request_error - code = previous_response_unavailable + code = previous_response_unavailable # 仅 managed 链已损坏/超限时 流式请求如果尚未开始下游 body: @@ -549,17 +531,15 @@ invalid_encrypted_content 和 tool output 缺失属于不同失败种类,可 不得把完整上游 JSON 嵌套进 stream_disconnected message 返回给客户端。 -### 14.4 同账号重放 +### 14.4 同账号优先、调度换号 -有快照的恢复必须固定到刚才使用的账号: +managed history 的恢复顺序固定为: - next_required_account_id = Some(release_account_id) +1. 所有权账号携带原 previous ID 直接续接。 +2. 精确 history error 或连接不可用时,在所有权账号做一次 full replay。 +3. 所有权账号发生账号级失败时,将其加入本请求排除集,再按运行时设置选择的调度策略租用其余候选;每个候选都使用同一份完整历史重建干净请求。 -原因: - -- reasoning replay 和 account scope 相关。 -- 避免恢复动作变成账号轮换。 -- 与现有 session affinity 语义一致。 +full replay 不携带旧 previous ID、账号绑定 turn state 或 encrypted reasoning。账号轮换必须发挥账号池作用,但不能绕过 Smart、quota-reset-priority、round-robin 或 sticky 等当前配置,也不能把一个节点的污染状态传播给其他节点。 ## 15. 流式提交和超时必须拆成三个概念 @@ -670,11 +650,12 @@ invalid_encrypted_content 和 tool output 缺失属于不同失败种类,可 修改: -- backend/src/dispatch/implicit_resume.rs -- backend/src/dispatch/responses/service.rs -- backend/src/dispatch/responses/service/complete.rs -- backend/src/dispatch/responses/service/stream.rs -- backend/src/dispatch/responses/sse_failure.rs +- backend/src/dispatch/recovery/history.rs +- backend/src/dispatch/service.rs +- backend/src/dispatch/stream/lifecycle.rs +- backend/src/dispatch/stream/live.rs +- backend/src/dispatch/stream/sse_failure.rs +- backend/src/dispatch/affinity/resolve.rs 引入 HistoryRecoveryPlan,延后快照 clone,删除显式 history 的不安全 strip-and-retry。 @@ -733,9 +714,9 @@ invalid_encrypted_content 和 tool output 缺失属于不同失败种类,可 ### 17.4 dispatch 恢复测试 -1. implicit resume + 池连接丢失:直接恢复完整 input,只向上游发送一次完整请求。 -2. explicit previous ID + 池连接丢失:返回 previous_response_unavailable,上游零请求。 -3. implicit resume 在匹配连接上收到: +1. managed history + 池连接丢失:所有权账号使用完整 input 恢复,不在新连接发送旧 ID。 +2. external unknown previous ID:首个调度账号原样尝试一次;失败后原样返回,不向其余账号广播。 +3. managed history 在匹配连接上收到: response.created response.content_part.added @@ -743,11 +724,11 @@ invalid_encrypted_content 和 tool output 缺失属于不同失败种类,可 断言结构事件未下发,完整请求重放一次。 -4. explicit previous ID 收到同样错误:不重放。 +4. external unknown previous ID 收到同样错误:不重放、不换号。 5. 真实 output_text.delta 后再失败:不重放。 6. function call 参数产生后再失败:不重放。 -7. recovery plan 消耗后第二次错误不再重试。 -8. 恢复固定在原账号。 +7. 同一候选的 owner replay 只执行一次,账号级失败后该账号不会再次进入本请求。 +8. 恢复先走所有权账号,随后按当前设置选定的调度策略遍历剩余候选。 9. 测试模型至少包含 gpt-5.6-sol,但实现断言不能检查模型名。 ### 17.5 延迟和超时测试 @@ -764,11 +745,11 @@ invalid_encrypted_content 和 tool output 缺失属于不同失败种类,可 普通日志和 ops error metadata 增加: - history_source = none | explicit | implicit + history_source = none | managed | external_unknown history_replay_available = true | false ws_continuation = new_chain | persisted | matched | missing | mismatch | busy recovery_action = none | replay_full | reject - recovery_attempt = 0 | 1 + recovery_attempt = 0 | owner_replay | scheduled_candidate prefetch_policy = first_content | output_or_terminal upstream_request_sent = true | false initial_event_ms @@ -842,8 +823,9 @@ WebSocket audit 继续脱敏: | 场景 | 预期 | | --- | --- | | gpt-5.6-sol,同 socket 连续两轮 | 第二轮 matched,正常增量 | -| 第一轮完成后主动 discard socket,第二轮为显式 ID | 本地 previous_response_unavailable,上游零请求 | -| 第一轮完成后 discard,第二轮为代理 implicit resume | 直接发送完整 input,不发送旧 ID | +| 第一轮完成后主动 discard socket,第二轮为 managed ID | 所有权账号直接发送完整 input,不在新连接发送旧 ID | +| 外部未知 previous ID | 仅首个调度账号原样尝试;失败后保留上游 status/type/code/message/param | +| 所有权账号 full replay 发生账号级失败 | 按当前运行时调度策略尝试剩余候选,且每次都从完整历史重建 | | store=true,第一轮 socket 丢失 | 允许新连接尝试 hydration | | 同 conversation 并发导致 Busy | 不创建新连接发送 connection-local ID | | 结构事件后 upstream not found,且可重放 | 客户端不看到旧结构事件,完整重放成功 | @@ -875,7 +857,7 @@ WebSocket audit 继续脱敏: ## 23. 回滚 -本方案不引入数据库 schema,也不依赖 Redis 持久化,因此回滚只涉及二进制和配置。 +本方案不引入 PostgreSQL schema,但新增 `affinity:v2:*` Redis 逻辑结构并依赖 Redis 持久化维持跨进程 managed replay。回滚只涉及二进制和配置;旧版本不会读取这些键,遗留键按 TTL 自然过期,不增加兼容读取路径。 如果采用新的超时字段,回滚旧版本前必须同步恢复旧配置字段,否则旧版本会因 deny_unknown_fields 拒绝启动。 @@ -894,7 +876,8 @@ WebSocket audit 继续脱敏: - store=false 旧 ID 不会发送到新连接。 - 连接只保存最近一个成功 completed response ID。 - 显式增量请求没有完整历史时不会伪恢复。 -- 隐式续接丢失连接时能够用完整输入恢复。 +- managed 续接丢失连接时能够用完整输入恢复,并可在账号级失败后调度剩余候选。 +- external unknown previous ID 不会广播到账号池。 - 上游 exact code 结构化分类,不靠 message 判断本错误。 - 已输出后不重试。 - 正常请求不承担全局预取延迟。 @@ -922,7 +905,7 @@ CLIProxyAPI: - sub2api 的 message/场景启发式不能替代强类型连接状态。 - CLIProxyAPI 的完整 transcript 常驻策略会增加内存和隐私边界。 -- 本工程已有 ImplicitResumeSnapshot,应该复用现有完整输入能力,而不是再建第二套 transcript 缓存。 +- 本工程只保留 Redis `ResponseReplaySnapshot` 增量链,不再保留进程内 `ImplicitResumeSnapshot` 或第二套 transcript 缓存。 ## 26. 最终建议 @@ -937,4 +920,4 @@ CLIProxyAPI: 能力感知预取和超时状态拆分必须与流式回归测试一起完成,不能只移植全局 UntilOutputOrTerminal。 -这套实现既能解决当前 GPT-5.6 事故,也保持模型无关、低延迟、无额外持久化和清晰的状态所有权。 +这套实现既能解决当前 GPT-5.6 事故,也保持模型无关、低延迟、有界 Redis 持久化和清晰的状态所有权。 diff --git a/docs/previous-response-websocket-recovery-implementation.md b/docs/previous-response-websocket-recovery-implementation.md index 3b89e2b48..53bae3069 100644 --- a/docs/previous-response-websocket-recovery-implementation.md +++ b/docs/previous-response-websocket-recovery-implementation.md @@ -47,7 +47,7 @@ - 给 body 注入客户端没有提供的业务默认值。 - 给缺失的 `prompt_cache_key` 生成值,或生成/覆盖 `previous_response_id`、input、reasoning、tools、text、service tier。 - 覆盖普通客户端 metadata;只有明确列入安全策略的 reserved identity key 可以被账号作用域值替换。 -- 在正常路径为了优化而把完整请求改写成增量请求;账号 failover 只有持有完整快照时才允许派生语义等价的完整请求。 +- 在正常路径为了优化而把完整请求改写成增量请求;账号 failover 只有持有完整可重建的 replay 链时才允许派生语义等价的完整请求。 - 把可由客户端处理的上游 4xx code、message、param 或 JSON body 包装成另一种错误。 - 给成功响应增加便利字段,或改写上游已经返回的 output/event 内容。 @@ -77,12 +77,12 @@ | 故障域 | 识别依据 | 动作 | | --- | --- | --- | | 节点级 | 精确 token/quota/account/model capability,或只在一个账号出现的 cookie/连接故障 | 隔离该账号,清理其连接/状态,使用干净 envelope 继续下一候选 | -| 会话污染 | previous ID、turn state、加密 reasoning 与目标账号/socket 不匹配 | 不发送污染值;从完整快照派生 full replay | +| 会话污染 | previous ID、turn state、加密 reasoning 与目标账号/socket 不匹配 | 不发送污染值;从完整可重建的 replay 链派生 full replay | | 请求级 | schema、param、context、tool output、policy 等确定性 4xx | 立即停止 fanout,原样返回,不触碰其他账号 | | 单节点风控 | challenge/path-block/new-ban 等结构化信号 | 只隔离该账号的 cookie、连接和状态,重建干净 envelope 后继续遍历当前设置所选调度策略排序后的剩余候选 | | 共享上游 | 多账号返回相同明确的 global overload/maintenance 信号 | 仅作为当前请求的结构化失败证据,不打开 endpoint circuit,不修改未证实异常的账号状态 | -风险剖离不得变成请求级熔断。任何账号出现风控信号时,只清理该账号及该连接绑定的 cookie、turn state、previous ID、encrypted reasoning 和 identity;换号仍调用当前设置所选调度策略,不改成固定 Smart、插入顺序、tier 顺序或手工风险队列。只有确定性客户端请求错误、外部未知 history 缺少完整快照或输出已提交时才停止跨账号重放。 +风险剖离不得变成请求级熔断。任何账号出现风控信号时,只清理该账号及该连接绑定的 cookie、turn state、previous ID、encrypted reasoning 和 identity;换号仍调用当前设置所选调度策略,不改成固定 Smart、插入顺序、tier 顺序或手工风险队列。只有确定性客户端请求错误、外部未知 history 缺少完整可重建链或输出已提交时才停止跨账号重放。 每次 failover 必须从不可变请求重新构造,不能在同一个可变 JSON 上连续剥字段。否则第一个节点的响应状态、cookie、turn state 或恢复 patch 会悄悄进入后续所有节点,形成真正的池级污染。 @@ -213,7 +213,7 @@ Add ordinals to paginated rollout records (#32332) 目标实现必须先由 pool/scheduler 生成稳定的候选 ID 快照,再由 dispatch 维护逐账号 attempt ledger。快照包含所有基础可用层级,tier/affinity/策略只负责排序;账号凭据和运行状态在真正租用时重新读取,避免长请求持有过期 token。ledger 中每个候选只能进入一次账号级尝试,单账号内部允许独立的有界 transport retry;瞬时 Busy 候选先延后再回访。最终错误必须能证明候选总数、已尝试数和被状态排除数相等,而不是根据某个固定计数猜测“应该没有账号了”。 -每个 attempt 都从不可变的客户端原始请求或完整 replay snapshot 构造新请求,并重新绑定所选账号的 Authorization、account ID、cookie、Cloudflare 上下文、WebSocket 和冷却状态。前一 attempt 的可变 request、turn state、连接 metadata 或加密 reasoning 不能被下一账号继承。 +每个 attempt 都从不可变的客户端原始请求或完整重建的 replay input 构造新请求,并重新绑定所选账号的 Authorization、account ID、cookie、Cloudflare 上下文、WebSocket 和冷却状态。前一 attempt 的可变 request、turn state、连接 metadata 或加密 reasoning 不能被下一账号继承。 ### 4.4 上游传输 @@ -389,7 +389,7 @@ origin store = false failure account = origin account ``` -原响应连接中 14 个为 reuse、2 个为 new;后续 211 个失败全部在 new 或 bypass 连接发生。因此账号轮换不是这批生产事故的根因,连接本地缓存丢失才是共同条件。但每个失败请求也都只尝试了一个账号,说明 history error 分支没有发挥账号池;修复不能简单把旧 ID 继续发给下一账号,必须先从完整快照重建干净请求。 +原响应连接中 14 个为 reuse、2 个为 new;后续 211 个失败全部在 new 或 bypass 连接发生。因此账号轮换不是这批生产事故的根因,连接本地缓存丢失才是共同条件。但每个失败请求也都只尝试了一个账号,说明 history error 分支没有发挥账号池;修复不能简单把旧 ID 继续发给下一账号,必须先从完整可重建链生成干净请求。 所有 211 个失败 wire request 都包含 previous ID,`store=false`,input item 数为 1 到 8(p50=4,p95=7),符合增量请求形态。现有日志没有记录 previous ID 是客户端显式提供还是代理 implicit resume 生成,因此后续必须记录 provenance,不能靠 input 长度推断。 @@ -431,7 +431,7 @@ failure account = origin account 3. 只有成功的 `response.completed` 才更新连接的 latest response ID。 4. `response.incomplete`、`response.failed`、错误、取消和未完成 EOF 都必须使本轮无法成为续接基线。 5. 正常路径原样转发客户端请求,不主动把完整请求优化成增量请求。 -6. 原账号或原 socket 不可用时必须继续账号轮换;有完整快照时清除旧 ID 和账号绑定状态,在本请求剩余的每个候选账号执行 full replay,直至成功或候选耗尽。 +6. 原账号或原 socket 不可用时必须继续账号轮换;有完整可重建的 replay 增量链时清除旧 ID 和账号绑定状态,在本请求剩余的每个候选账号执行 full replay,直至成功或候选耗尽。 7. stale-reuse 失败后不得把原增量 payload 或同一 previous ID 原样发给新连接/新账号。 8. full replay 只能发生在任何真实输出提交给客户端之前,并且每次派生都必须可审计。 9. `store=true` 的服务端持久化语义要与连接本地 fast path 分开处理,不能共用一个布尔判断。 @@ -444,24 +444,22 @@ KnownPersisted # 代理观察到前一响应已持久化,可在新连 ExternalUnknown # 客户端外部提供,代理不知道来源 ``` -`KnownConnectionLocal` 丢失原 socket 后,使用完整快照在原账号的新连接 full replay;若该账号同时发生账号级故障,再依次重放到其余候选账号。`KnownPersisted` 可以先在符合账号所有权的连接尝试 hydration,失败后同样走 full replay 和完整候选遍历。`ExternalUnknown` 没有代理历史时只能原样尝试;若上游返回 not found,必须原样返回,不能删除 ID 后把不完整 input 当新会话发送,更不能把未知 ID 广播给所有账号。 +`KnownConnectionLocal` 丢失原 socket 后,使用完整可重建的增量链在原账号的新连接 full replay;若该账号同时发生账号级故障,再依次重放到其余候选账号。`KnownPersisted` 可以先在符合账号所有权的连接尝试 hydration,失败后同样走 full replay 和完整候选遍历。`ExternalUnknown` 没有代理历史时只能原样尝试;若上游返回 not found,必须原样返回,不能删除 ID 后把不完整 input 当新会话发送,更不能把未知 ID 广播给所有账号。 -账号轮换需要新的完整恢复快照,而不是沿用当前只保存 reasoning/function call 的 replay cache: +账号轮换需要一条完整可重建、容量受限的恢复链,而不是在每个响应节点重复保存累计输入: ```text ResponseReplaySnapshot - response_id - owner_account_id - source_store - request_properties - full_input_through_completed_response - owner_turn_state - created_at + parent_response_id + turn_input + turn_output + depth + total_bytes ``` -快照应放在 Redis 独立 TTL key 中,与 affinity 同步提交和删除,不写 PostgreSQL,不进入日志。快照只保存恢复所需的业务上下文,不保存 access token、account cookie、上游 account ID、指纹或 Cloudflare 凭据。正常请求仍使用客户端原始 body;只有账号/连接恢复分支读取快照并派生 full replay。跨账号时只剥离明确账号绑定的 `previous_response_id`、turn state 和加密 reasoning 内容,保留 reasoning summary、tool call、tool output 及所有其他业务字段。 +快照作为 `affinity:v2:resp:` 值的一部分,与 affinity 同步提交和删除,不写 PostgreSQL,不进入日志。每个 completed response 只保存本轮 input/output 增量和父 response ID;读取时沿父链重建完整业务上下文,Redis 空间随轮数线性增长,不再出现累计 `full_input` 的 O(n²) 放大。单节点 JSON 上限 2 MiB、单会话累计上限 16 MiB、最大 128 层、TTL 4 小时;超限只停止为该响应保存 replay,不截断或修改正常响应。快照不保存 access token、account cookie、上游 account ID、指纹或 Cloudflare 凭据;递归删除 `encrypted_content` 和顶层上游对象 `id`,保留 `call_id`、reasoning summary、tool call/output 等协议关联字段。正常请求仍使用客户端原始 body;只有账号/连接恢复分支读取增量链并派生 full replay。 -当前 `ImplicitResumeSnapshot` 只在代理主动做隐式续接时保存本次完整 input,无法覆盖客户端显式增量请求,也无法跨请求生存;它不能承担账号轮换恢复,应由上述完整快照替代。 +旧 `ImplicitResumeSnapshot` 与主动 implicit resume 路径已删除。managed history 只有这一条 Redis 增量链恢复模型,不保留第二套临时快照或兼容读取。 ### 6.4 推荐状态 @@ -474,12 +472,9 @@ PooledWebSocketConnection created_at continuation latest_response_id - owner_account_id - source_store - replay_snapshot_key ``` -同 socket 快路径只校验精确 latest response ID;代理不替客户端判断或改写其他业务字段。需要换 socket 或换账号时,每个候选都通过 replay snapshot 构造独立的派生请求,不修改保留的客户端原始 request,也不复用上一个 attempt 的派生 request。 +同 socket 快路径只校验精确 latest response ID;账号所有权与 replay 增量链由 Redis affinity 条目表达,不在连接中复制。需要换 socket 或换账号时,每个候选都通过重建后的 replay input 构造独立派生请求,不修改保留的客户端原始 request,也不复用上一个 attempt 的派生 request。 不要把连接代次或 latest response ID 写入 Redis。Redis 无法证明当前进程中的具体 socket 仍存活,反而会制造第二真相源。 @@ -742,7 +737,7 @@ request-id | --- | --- | --- | --- | | 确定性请求错误 | schema/param 非法、上下文超限、缺 tool output | 不广播到其他账号 | 原样保留上游 status/type/code/message/param/body | | 外部未知 history | 无快照的 `previous_response_not_found` | 不把未知 ID 广播到其他账号 | 原样返回上游 400 | -| 可恢复 managed history | connection-local ID 丢失、invalid encrypted content | 从完整快照派生 full replay;账号级失败时继续剩余候选 | 成功响应,或候选耗尽后的统一错误 | +| 可恢复 managed history | connection-local ID 丢失、previous response not found | 从完整可重建的增量链派生 full replay;账号级失败时继续剩余候选 | 成功响应,或候选耗尽后的统一错误 | | 节点级认证 | 精确 token invalid/expired/revoked | 更新账号状态并排除,继续全部剩余候选 | 候选耗尽后返回 `no_available_accounts` | | 高风险账号状态 | account deactivated/banned | 隔离当前账号与连接,重建干净 envelope 后继续当前设置所选调度策略 | 候选耗尽后返回 `no_available_accounts` | | 账号额度/单节点风控 | 429、quota、Cloudflare challenge/path block | 写冷却/状态并排除,使用干净 envelope 继续遍历当前设置所选调度策略排序后的剩余候选 | 候选耗尽后返回 `no_available_accounts` | @@ -779,7 +774,7 @@ request-id 2. 同账号 5xx 使用 200ms 起步的有界指数退避和 0.9 到 1.1 抖动;预算用尽后排除当前账号并继续下一候选。 3. 对 429 使用服务端 retry-after 和账号冷却,不做同账号快速重试,立即继续下一候选。 4. quota verify、model unsupported、empty response、token invalid/expired、Cloudflare 和 new-ban 都计入当前账号的 attempt,不能使用小于候选数的全局固定上限。 -5. managed previous history 错误在每个候选上只允许一次有完整快照证明的 full replay;不能复用旧 ID,也不能重复使用上一个账号的派生 request。 +5. managed previous history 错误在每个候选上只允许一次由完整增量链证明的 full replay;不能复用旧 ID,也不能重复使用上一个账号的派生 request。 6. 确定性客户端 4xx 和外部未知 history 不做账号 fanout,避免用无效请求伤害整个账号池。 7. 一旦真实输出已经提交给客户端,不做可能重复输出或副作用的 full replay。 8. 客户端取消、确定性请求错误、输出已提交、进程关闭或候选 ledger 已耗尽可以结束 dispatch;普通 node-local 重试计数不能提前返回客户端错误。 @@ -841,10 +836,10 @@ request-id 1. pool/scheduler 一次返回跨全部可用层级的有序候选 ID 快照;tier/affinity 只排序,瞬时 Busy 延后回访,dispatch 使用 attempt ledger 证明全部候选已处理。 2. 删除 `MAX_QUOTA_VERIFY_ATTEMPTS` 和 model unsupported 单次换号闸门;账号级错误、5xx/transport retry exhausted 和 empty response 都继续剩余候选。 3. 风控只修改当前账号的状态并清理其连接绑定信息;不引入 request risk guard、endpoint circuit 或换号次数/时间预算。 -4. 每个 attempt 从不可变原始请求/完整快照重新构造,并绑定该账号独立的凭据、cookie、风控状态和连接。 +4. 每个 attempt 从不可变原始请求/重建后的完整业务输入重新构造,并绑定该账号独立的凭据、cookie、风控状态和连接。 5. 连接对象拥有 continuation state,发送前验证 previous ID、owner account、socket 和请求形态;新连接不携带 connection-local ID。 -6. Redis 保存唯一的完整 replay snapshot;删除主动 implicit resume 优化及其临时快照,不保留两套恢复模型。 -7. history failover 只从完整快照派生,跨账号剥离旧 ID、turn state 和账号绑定加密内容,业务上下文保持等价。 +6. Redis 保存唯一的有界 replay 增量链;删除主动 implicit resume 优化及其临时快照,不保留两套恢复模型。 +7. history failover 只从校验完整的增量链派生,跨账号剥离旧 ID、turn state 和账号绑定加密内容,业务上下文保持等价。 8. 删除全局 output-prefetch,拆分 activity timeout 与 commit boundary;提交前可 failover,提交后禁止盲目重放。 ### 阶段二:终态和结构化错误 @@ -905,7 +900,7 @@ request-id | pool Busy | 旁路连接不携带池连接的 previous ID | | `response.incomplete` | 不更新 latest ID、affinity 或 replay | | completed 后客户端取消下一轮 | 被取消轮不推进 continuation | -| managed connection-local ID、连接缺失 | 从 Redis 完整快照重放,不带旧 ID/turn state/账号绑定加密内容 | +| managed connection-local ID、连接缺失 | 从 Redis 完整增量链重建并重放,不带旧 ID/turn state/账号绑定加密内容 | | managed ID 的快照意外缺失 | 视为内部不变量破坏,不把残缺请求发往任何账号 | | 外部来源未知的显式 previous ID | 新连接尝试一次;not found 时原样返回,不广播未知 ID | | 原账号新连接 full replay 仍发生账号级失败 | 排除原账号,并从同一业务快照依次派生到全部剩余候选 | @@ -1003,12 +998,12 @@ effective_model - 代理已知源自 `store=false` 响应的旧 ID 不会出现在新 socket 的上游 payload。 - continuation state 由具体连接拥有,连接销毁时同步销毁。 - 只有 `response.completed` 推进续接状态。 -- 代理管理的每条 continuation 都有 Redis 完整 replay snapshot,快照不包含任何账号凭据或 cookie。 +- 代理管理且未超限的 continuation 都有 Redis 可完整重建的 replay 增量链;节点/会话/深度/TTL 边界明确,链中不含任何账号凭据或 cookie。 - 显式增量请求没有完整历史时不会伪恢复,也不会把未知 ID 广播到账号池。 - 普通业务 body 原样转发;reserved identity 做可审计的账号作用域转换,只有账号 failover 分支派生 full replay。 - 每次 attempt 都重建账号 envelope,前一账号的 Authorization、account ID、cookie、turn state、socket 和加密 reasoning 不会进入下一账号。 - prompt/session/thread/window/installation 等关联标识按账号隔离,普通 metadata 不被风控转换误伤。 -- node-local token 失效、额度、模型能力、可恢复 5xx/transport、空响应和高风险信号会隔离当前账号并继续遍历当前设置所选调度策略排序后的剩余候选;只有确定性请求错误、外部未知 history 无完整快照或输出已提交可以停止跨账号重放。 +- node-local token 失效、额度、模型能力、可恢复 5xx/transport、空响应和高风险信号会隔离当前账号并继续遍历当前设置所选调度策略排序后的剩余候选;只有确定性请求错误、外部未知 history 无完整可重建链或输出已提交可以停止跨账号重放。 - 不存在 request risk guard、endpoint circuit、候选次数上限或换号时间预算;风险剖离只保护已失败账号,不改写当前设置所选策略的候选顺序。 - quota verify、model unsupported 或普通重试计数不会在候选耗尽前结束 dispatch。 - 最终池耗尽错误有 `candidate_count = attempted + state_excluded` 的 ledger 证据。 @@ -1036,7 +1031,7 @@ CODEX_PROXY_WS_AUDIT_DIR=.runtime/ws-audit 1. 同 socket 连续两轮正常增量。 2. 完成后主动 discard,再发送代理已知 connection-local previous ID。 3. 在新连接发送外部来源未知、可能已持久化的 previous ID。 -4. 完成后主动 discard,使用 Redis 完整快照在原账号新连接重放。 +4. 完成后主动 discard,使用 Redis 增量链在原账号新连接完整重放。 5. 原账号分别制造 429、quota、auth、首次 Cloudflare、model unsupported 和 retryable 5xx,验证每类单节点错误都会进入下一候选。 6. 让前 N-1 个候选发生 node-local 失败、最后一个成功,确认客户端只看到成功且 attempt ledger 完整。 7. 让全部候选发生 node-local 失败,确认最后一个候选结束前不会返回客户端错误。 @@ -1070,7 +1065,7 @@ immutable client request -> per-account identity pseudonyms -> bounded same-account transport retry -> exclude failed account and continue - -> full replay snapshot for history failover + -> bounded replay delta chain for history failover -> success or proven candidate exhaustion connection-owned continuation state @@ -1083,6 +1078,6 @@ connection-owned continuation state ## 21. 实施状态 -P0/P1 已按上述边界实施:完整候选 ledger 使用运行时设置当前选择的策略顺序(Smart / QuotaResetPriority / RoundRobin / Sticky),没有换号次数/时间预算或风险熔断;managed history 换号只从 Redis 完整快照重放,external unknown history 不广播;WebSocket continuation 由连接拥有,只有 `response.completed` 推进状态。 +P0/P1 已按上述边界实施:完整候选 ledger 使用运行时设置当前选择的策略顺序(Smart / QuotaResetPriority / RoundRobin / Sticky),没有换号次数/时间预算或风险熔断;managed history 换号只从 Redis 校验完整的有界增量链重放,external unknown history 不广播;WebSocket continuation 由连接拥有,只有 `response.completed` 推进状态。 请求 body 不再注入代理默认值,identity 和 installation ID 使用持久 HMAC 密钥按字段域/账号隔离;`response.incomplete` 不写续接基线;HTTP/SSE/WS 捕获并消费 effective model、models ETag、reasoning included 和任意 limit ID/credits/plan/promo/reached type。P2 协议能力仍保持独立评审边界。 diff --git a/docs/real-account-regression-2026-07-12.md b/docs/real-account-regression-2026-07-12.md new file mode 100644 index 000000000..c9983383f --- /dev/null +++ b/docs/real-account-regression-2026-07-12.md @@ -0,0 +1,135 @@ +# 真实账号回归报告(2026-07-12) + +## 1. 范围与环境 + +- 分支:`feat/postgres-redis-migration` +- 基线提交:`00fb1465b05bfbddb03a2463207066d89b17c9fe` +- 真实账号:2 个,均为 `active / k12`,账号文件权限已收紧为 `0600` +- 共同可用模型:7 个;回归模型为 `gpt-5.6-sol` +- 镜像:`codex-proxy-rs:real-test-20260712` +- 镜像 ID:`sha256:308ef0e1c6dac2f83a2de30bebe85ead32b779913011c6a7bbcfc71d1aea412e` +- 镜像大小:41,127,442 bytes +- 隔离服务:`http://127.0.0.1:18082` +- Compose 项目:`cpr-real-20260712` + +本文只记录聚合结果。账号 ID、邮箱、access/refresh token、Cookie、客户端密钥、 +response ID、会话 ID 和上游 request ID 均未写入报告。 + +## 2. 质量门禁 + +| 门禁 | 结果 | +| --- | --- | +| Rust 格式 | `cargo fmt --all -- --check` 通过 | +| Rust 静态检查 | Clippy `--all-targets --all-features -D warnings` 通过 | +| 后端测试 | 658 passed,0 failed,0 ignored | +| 前端格式 | Prettier 通过 | +| 前端类型与生产构建 | `vue-tsc -b && vite build` 通过,2,837 modules transformed | +| GitHub Actions | Actionlint 通过 | +| Rust 依赖 | RustSec 扫描 339 个 crate,0 漏洞 | +| 前端依赖 | pnpm production audit,0 漏洞 | +| 最终镜像 | Trivy HIGH/CRITICAL、OS/library、ignore-unfixed:0 | +| Compose | `docker compose config --quiet` 通过 | + +## 3. 真实请求矩阵 + +| 场景 | 结果 | +| --- | --- | +| 新库启动、迁移、PG/Redis 健康检查 | 通过;迁移为 `1 / initial`,checksum 64 字符 | +| 管理端登录、状态、`Cache-Control: no-store` | 通过 | +| 模型快照 | 两个账号均返回相同的 7 个模型 | +| HTTP SSE | 200,包含 created/completed/[DONE],输出完整 | +| WebSocket 转 SSE | 200,包含 created/completed/[DONE],输出完整 | +| 同连接 `previous_response_id` 续接 | 通过 | +| 应用重启后的托管会话续接 | 通过;首次续接不可用后完整 replay,`attemptCount=2` | +| 日常多轮对话 | low/medium/high/max 均覆盖;计划、修改计划、摘要等场景通过 | +| 会话 owner 被禁用后换号 | 通过;按运行时调度策略选择其他账号,`account_switched=true` | +| 外部未知 previous response | 只请求一个账号;原样返回 400,未遍历账号池 | +| 智能、额度重置优先、轮询、粘性策略 | 全部实际切换并完成;轮询连续请求命中两个不同账号 | +| 4 路并发 | 全部 200/completed,两个账号各承载 2 路 | +| 客户端取消 | 客户端超时后下一请求仍成功,健康检查仍为 204 | +| 客户端密钥禁用/启用 | 禁用后 401,恢复后 200;`last_used_at` 已持久化 | +| 无密钥、错误密钥、未知模型、坏 JSON、非对象 JSON、超大请求体 | 分别按契约返回 401/404/400/413 | +| Codex `input` 字符串 | 上游原样返回 400 `Input must be a list`;改为标准 item 列表后 200 | +| 全账号禁用 | 返回 `no_available_accounts` 终止事件,未向上游穿透;恢复后正常 | +| 非法账号导入 | 400,账号总数保持 2,未留下半初始化记录 | +| SPA 首页与静态资源 | 200 | +| PG/Redis 卷重建 | 数据、会话、密钥和 affinity 均保持;重启后真实请求 200 | + +## 4. PostgreSQL 数据状态 + +最终持久化数据如下: + +| 表 | 行数 | 核对结果 | +| --- | ---: | --- | +| `schema_migrations` | 1 | `initial`,checksum 长度 64 | +| `accounts` | 2 | 均为 active/k12;无 quota/Cloudflare 冷却 | +| `account_usage` | 2 | 与账号一一对应,无孤儿 | +| `account_cookies` | 0 | 风控清理后无遗留 Cookie | +| `admin_users` | 1 | 唯一管理员记录 | +| `client_api_keys` | 1 | enabled,且已有 `last_used_at` | +| `fingerprints` | 1 | 当前指纹 | +| `fingerprint_update_history` | 1 | 更新历史可查询 | +| `runtime_settings` | 1 | 策略已恢复为 smart | +| `usage_records` | 21 | 21 个不同 request ID,全部为真实成功请求 | +| `ops_error_logs` | 11 | 11 个不同 request ID,均为预期边界/上游失败事实 | +| `request_time_buckets` | 10 | 成功/失败与两张事实表精确一致 | + +### 4.1 成功事实与账号统计 + +- 成功明细:21;HTTP SSE 1,WebSocket 20;涉及 2 个账号、1 个模型。 +- 推理强度:high 2、low 11、medium 3、max 1、未显式指定 4。 +- token:input 2,340、output 1,917、cached 0、reasoning 596、合计 4,257。 +- 账号累计请求尝试:32;空响应 0。它包含失败尝试,因此大于 21 条成功事实。 +- 平均端到端延迟 4,473 ms;平均首字延迟 2,586 ms。 + +### 4.2 错误事实与统计桶 + +- 400/400/400:HTTP SSE 3、WebSocket 4,均为上游请求级错误。 +- HTTP 已提交后捕获的上游 400:WebSocket 2,客户端状态按实际响应保持 200。 +- `no_available_accounts`:2,客户端状态 503,未产生上游状态。 +- 桶合计:`success_count=21`、`error_count=11`,分别等于成功与错误事实表行数。 +- 桶 token 合计与 `usage_records` 完全一致;负计数、`min_latency > max_latency` 均为 0。 + +### 4.3 一致性、容量与保留策略 + +- 账号用量、Cookie、成功/错误账号引用、客户端密钥引用的孤儿数均为 0。 +- 未验证约束、无效索引、未 ready 索引均为 0。 +- 数据库约 9.12 MB;12 张业务表连同索引约 0.92 MB。 +- 保留周期:usage 30 天、ops 30 天、bucket 90 天。 +- 已分别插入 31/31/91 天的隔离标记并重启任务,三类过期标记均从 1 清理为 0。 + +## 5. Redis 数据状态 + +- 总键数 42;STRING 23、ZSET 18、HASH 1。 +- 41 个键带 TTL;唯一常驻键为 `models:plan_snapshots` HASH。 +- Redis 逻辑数据约 36 KB;单键最大约 6.3 KB。 +- affinity:response 21、conversation 16、account 2。 +- 管理会话 2,均有 TTL;模型计划 field 1;刷新 lease 与登录节流残留均为 0。 +- replay 最大深度 4、最大累计字节 3,068、最大节点值 1,671 bytes。 +- response 到 conversation/account 的缺失成员均为 0;父链缺失和索引孤儿均为 0。 +- replay 无 TTL、无效 JSON、敏感凭据和 `encrypted_content` 命中均为 0。 +- AOF 已启用;AOF 写入、重写和 RDB 保存状态均为 `ok`。 + +## 6. 测试中发现并修复 + +1. PostgreSQL Alpine 初始化存在 locale/trust 警告:改为固定 digest 的 Bookworm,显式使用 SCRAM。 +2. 可恢复的上游失败在最终成功前被提前记录为 WARN:删除中间告警,终态事件唯一负责级别。 +3. 流提交前的上游 400 被写成 `client_status_code=200`:改为记录实际返回状态;提交后的流仍保持 200。 +4. 缺失、错误或禁用客户端密钥属于预期请求结果却写 WARN:改为 INFO;存储不可用仍为 WARN。 + +## 7. 最终运行态 + +- 应用、PostgreSQL、Redis 三个容器均为 healthy。 +- 应用以 `10001:10001` 运行,使用最终镜像 ID。 +- 三个容器日志轮转均为 `json-file / 10m / 5`。 +- 最终重建后的应用日志只有 INFO;PG 的 WARNING/ERROR/FATAL/PANIC 为 0;Redis warning 为 0。 +- `/healthz` 返回 204;标准 Codex item-list 请求返回 200/completed/[DONE]。 + +## 8. 有意不执行的破坏性场景 + +- 未故意损坏或消费真实 refresh token,未主动制造封号、Cloudflare 风控或 429。 +- 未对真实账号执行高频压测或上千账号遍历,避免污染账号环境和额度。 +- 未破坏 PG 数据文件、Redis AOF 或模拟宿主机磁盘写满。 + +上述错误分类、账号轮换、全候选遍历、PG/Redis 写失败降级、并发槽位和 WebSocket 池取消安全, +均由 658 项集成测试中的确定性故障注入覆盖。 diff --git a/docs/security-review.md b/docs/security-review.md new file mode 100644 index 000000000..742296ffb --- /dev/null +++ b/docs/security-review.md @@ -0,0 +1,102 @@ +# 安全审查(backend) + +> 审查于 `feat/postgres-redis-migration` 工作树(fleet 改名之后)。覆盖静态凭据存储(PG 明文 vs 加密)、鉴权路径(admin 密码 / admin API key / client API key / 会话)、客户端 IP 信任边界、限流与爆破防护、admin 会话生命周期、自更新供应链、secrets 加载与泄露面、CORS / 安全响应头。所有结论均已用工具核到 `file:line`。 + +先说结论:**鉴权原语这一层做得很扎实**——admin 密码 argon2、admin API key 与会话 token 都是 SHA-256 摘要后再 `subtle::ConstantTimeEq` 比对、会话 token 256-bit 熵且 Redis 侧只存摘要、OAuth 走 PKCE+state、SQL 全参数化、容器非 root。真正的短板集中在**"静态凭据明文落库"**和**"信任边界"**两类。最需要处理的三条:(1) 上游 OAuth `access_token`/`refresh_token` 在 PG 明文存储;(2) client API key 同样明文存储且鉴权靠明文点查(与 admin key 的"存摘要"做法不一致);(3) `X-Forwarded-For` 等转发头无条件信任,导致登录失败锁定可被逐请求换 IP 绕过。这三条一条是"服务的核心资产裸奔",一条是"唯一的爆破防线形同虚设",都值得优先动手。 + +需要强调的是:明文落库两条属于**latent(潜在)风险——利用前提是攻击者已经能读到 PG**(备份泄露、只读副本、pg_hba 配错、内鬼、或未来引入的 SQLi)。当前代码 SQL 全参数化、没找到注入面,外部攻击者并没有现成路径读库。但对一个"核心资产就是这些 token"的服务,at-rest 加密是行业基线,所以仍按高优先级列出,而不是制造"正在被利用"的假紧迫感。 + +## P0 — 静态凭据明文落库 + +### 1. 上游 OAuth `access_token` / `refresh_token` 在 PostgreSQL 明文存储 + +- 现状:建表 `infra/migrations/0001_initial.sql:46-47` 是 `access_token text not null` / `refresh_token text`,没有任何加密列或 pgcrypto。写入侧 `fleet/store/write.rs:18-19` 直接 `.bind(account.access_token.expose_secret())` + 绑定 expose 后的 refresh_token,即**明文入库**。内存里的 `secrecy::SecretString` 只负责防止 Debug/日志误打印(抽查 `fleet/store/rows.rs`、`export.rs` 均正确走 `ExposeSecret`),**到了 PG 就退化成明文**。 +- 问题:这是整个服务最高价值的资产——一批可直接调用上游的 OAuth token。任何 DB 层面的暴露(备份/快照泄露、只读副本、误配 `pg_hba.conf`、运维/内鬼、未来的 SQLi)= 全量 token 立即可用,攻击者零额外成本(不需要爆破,不需要解密)。`refresh_token` 尤其致命:即便 access_token 短时过期,refresh_token 可持续换新。 +- 建议(按投入产出排序): + 1. **应用层 AEAD 加密**(推荐):用 `chacha20poly1305`/`aes-gcm`,主密钥从环境变量/KMS 注入(**不要**写进 `config.yaml`),写库前加密、读库后解密,`SecretString` 边界几乎不用改。附带一次密钥轮换预案。 + 2. 或 **pgcrypto `pgp_sym_encrypt`**:改动集中在 SQL,但密钥仍要在连接串/会话里传,防护面弱于应用层,且密钥容易随查询进日志。 + 3. 若短期不做加密,则必须**文档化并加固 DB 访问面**:副本/备份加密、最小化 `pg_hba` 网段、独立库权限,并在部署文档里明确"PG 一旦泄露即等同全量 token 泄露"。 +- 定级说明:利用前提是"已能读 PG",属 latent;但资产价值 + 修复成本低,按 P0 处理。 + +### 2. client API key 明文存储 + 明文点查(与 admin key 的摘要做法不一致) + +- 现状:建表 `0001_initial.sql:12` 是 `key text not null unique` ——**明文**;鉴权 `keys/store.rs:55` 是 `select id from client_api_keys where key = $1 and enabled`,拿请求里的明文 key 去 PG 做等值点查。对照之下,admin API key 是 `hash_credential`(SHA-256,`infra/identity.rs:86`)存摘要、`settings/service.rs:117` 用 `ct_eq` 比对;admin 密码是 argon2。**同一套系统里,client key 是唯一"明文落库"的凭据**。 +- 问题: + 1. **at-rest**:DB 泄露 → 所有 client key 直接可用(无需破解)。与 #1 同类,但这里连"高熵不可爆破"的兜底都用不上,因为拿到的就是明文。 + 2. **一致性**:admin 侧已经证明团队知道"存摘要 + 恒时比较"的正确姿势,client 侧却没套用,属明显遗漏而非设计取舍。 + 3. **管理端回显**:`api/admin/keys_routes.rs` 的 `ClientApiKeyData` 会把完整 `key` 序列化返回(list/get/create 都带),虽是 admin 鉴权后才可见,但意味着"任何能进管理台的人可随时导出全部明文 key"。 +- 关于"恒时比较"(任务问题 2 的直接回答):verify 路径是 `starts_with("sk_")` 预筛 + PG btree 等值点查(`keys/service.rs:49-56`),既**不是**应用层朴素 `==`(不存在经典的逐字节 Rust 比较时序 oracle),也**不是**显式恒时比较,而是把比较委托给了 Postgres 索引。加上 key 是 256-bit 随机(`identity.rs:63-68`),**时序侧信道在这里实际不可利用**(网络抖动远大于 btree 比较差异,且猜前缀无意义)。所以真正的问题是"明文 at-rest",而非"没恒时比较"——不必为时序问题制造紧迫感。 +- 建议:与 admin key 对齐——生成时 SHA-256 摘要入库(高熵密钥用 SHA-256 足够,不需要 argon2),鉴权改为按摘要点查;保留 `prefix` 列供管理台展示与定位;创建接口"仅此一次返回明文"(前端已有"完整 Key 仅显示一次"文案,后端配合即可)。 + +## P1 — 信任边界与供应链 + +### 3. 转发头无条件信任 → 登录失败锁定可绕过、审计可伪造 + +- 现状:`api/middleware/request_id.rs:130-138` 的 `auto_forwarded_client_ip` 依次取 `CF-Connecting-IP` → `X-Real-IP` → `X-Forwarded-For` 首个公网 IP,**没有可信代理(trusted proxy)白名单**,不校验直连 peer 是否是已知反代。也就是说这些头**可被任意客户端伪造**。解析出的 IP 一路用于:admin 登录锁定的 key(`auth_routes.rs:51` → `admin_login_source` → `SessionService::login` 的 `source`)以及审计(usage/ops 记录的 client_ip)。 +- 问题: + 1. **爆破防线被绕过(最实际)**:登录锁定按 `source`(=解析出的 IP)计数,5 次/15 分钟锁 15 分钟(`auth/service.rs:17-19,157-166`)。攻击者只要**每次请求换一个 `CF-Connecting-IP` 头**,每次都是"新来源",锁定永远不触发——唯一的在线爆破防护形同虚设。这条**匿名可直接利用**,不需要任何前置条件。 + 2. **审计可伪造**:usage/ops 里的 client_ip 可被随意伪造,事后取证与限流溯源不可信。 +- 建议: + 1. 引入**可信代理配置**:仅当直连 peer(`ConnectInfo`)属于已配置的反代网段时,才采信 `CF-Connecting-IP`/`X-Real-IP`/`XFF`;否则一律用 socket peer IP。这是修复根因。 + 2. 锁定逻辑**增加与 IP 无关的维度**:如按用户名(当前恒为默认 admin)或全局失败计数兜底,使"换 IP"无法把计数打散。 +- 说明:锁定状态是进程内 `HashMap`(`service.rs:28,51`),重启即清零、多实例不共享;当前单二进制部署可接受,若未来多实例需挪到 Redis。 + +### 4. 自更新无签名校验(供应链) + +- 现状:自更新会下载 GitHub Release 资产并替换正在运行的二进制。已有防护:HTTPS-only + host 白名单(`update/download.rs:125-152`,仅 github.com / objects.githubusercontent.com)、500MB 大小上限(`service.rs:34,477`)、SHA-256 checksum 校验(`download.rs:85-123`)、临时目录隔离 + `canonicalize`、rollback 支持。但 **checksum 来自同一个 Release 里的 `checksums.txt`**(`service.rs:480-485`),校验的是"下载内容 == 该 Release 自己声明的哈希",**没有任何针对发布者的密码学签名**(cosign/minisign/GPG)。 +- 问题:信任锚点退化为"GitHub 仓库本身 + TLS"。若 `CPR_UPDATE_REPOSITORY` 指向的仓库被攻破、维护者 token 泄露、或 Release 被替换,则 checksum 会跟着被一起改,校验必然通过——攻击者由此获得"替换运行中进程二进制"的能力(RCE 级)。更新虽需 admin 鉴权 + `build_type == release`(`service.rs:390-401`),但这只挡住外部触发,挡不住"上游包本身被投毒"。 +- 建议:引入**detached 签名验证**——用 minisign/cosign 对产物签名,公钥**编译进二进制**(不可通过配置替换),下载后先验签再落地;并在部署文档明确"仓库完整性 == 二进制完整性"。属设计取舍类,按 P1/中等优先级推进即可。 +- 记一笔做得好的:传输层卫生(HTTPS、host 白名单、size cap、checksum、rollback、temp 隔离)已经比"裸 curl | sh"强很多。 + +## P2 — 加固项,视精力而定 + +### 5. 缺少安全响应头(HSTS / X-Content-Type-Options / X-Frame-Options / CSP) + +- 现状:全仓 grep 不到任何 `Strict-Transport-Security` / `X-Content-Type-Options` / `X-Frame-Options` / `Content-Security-Policy` / `SetResponseHeader`(router 组装见 `api/router.rs:82-90`,只有 trace 与 request_id 两层)。管理台 SPA 与 admin API 同源提供。 +- 问题:admin UI 无 `X-Frame-Options`/CSP → 有点击劫持面;无 HSTS → 有协议降级面(会话 cookie 已是 `Secure`,风险被部分抵消)。 +- 建议:加一个轻量 `SetResponseHeaderLayer`:`X-Content-Type-Options: nosniff`、`X-Frame-Options: DENY`、`Strict-Transport-Security`(仅在确认整站 HTTPS 后)、以及一条最小 CSP。成本很低。 + +### 6. client 侧 `/v1/responses` 关闭了请求体大小限制 + +- 现状:`api/client/router.rs:26` 对整个 client 路由 `.layer(DefaultBodyLimit::disable())`,即公网 `/v1/responses` 等端点**请求体无上限**。admin 路由则沿用 axum 默认限制。 +- 问题:若请求体在转发前被缓冲(按 memory 记录的"typed rebuild"路径,body 会被解析重建),则单请求可撑爆内存,构成 DoS 向量。需确认缓冲行为(**待确认**:核对 `dispatch` 侧是否 streaming 透传还是先 `bytes()`/`to_vec()` 全量读入;验证方法——`rg -n "to_bytes|body::to_bytes|bytes\(\)|Vec" src/api/client src/dispatch`)。 +- 建议:即便需要支持大 payload,也应设一个"够大但有限"的上限(如 32/64MB),而非 `disable()`。 + +### 7. AdminError 把内部错误串直接回给客户端 + +- 现状:`AdminError::internal(...)`(`api/admin/response.rs:127`)以及若干 `error.to_string()`(如 `keys_routes.rs:81` 的 `client_key_error` internal 分支、`settings_persist_failed`)会把底层错误消息塞进响应 body 的 `message` 字段。 +- 问题:轻度信息泄露——可能回显存储层/依赖的实现细节。均为 admin 鉴权后接口,未发现直接回显 secret,故定 P2。 +- 建议:对外统一返回通用文案(如 "internal error"),底层 detail 只进日志(与 `telemetry/ops` 分工一致)。 + +### 8. 默认管理员密码在首启后仍留在 `config.yaml` + +- 现状:`admin.default_password` 首启用于播种管理员(`bootstrap/services.rs:492-512`),启动时强校验 ≥12 位且非弱口令(`config.rs:310-318`,弱口令表 `config.rs:5-14`)。校验做得好,但该密码之后**一直明文留在 config.yaml**。 +- 建议:文档提示"首启成功后可清空/轮换 `default_password`",避免长期明文留存;或改为只接受哈希/一次性播种标记。 + +## 做得好的(记录一下,免得反复纠结) + +- **admin 密码 argon2**:`infra/identity.rs:38-51`,`hash_admin_password`/`verify_admin_password` 走 argon2 + 随机 salt,姿势正确。 +- **admin API key 存摘要 + 恒时比较**:`identity.rs:86` SHA-256 摘要落库,`settings/service.rs:108-118` 用 `subtle::ConstantTimeEq::ct_eq` 比对,且空值短路——教科书做法。 +- **会话 token 强度与 Redis 侧防护**:`generate_admin_session_token` 是 256-bit CSPRNG(`identity.rs:79-83`,`rand::rng()` = ThreadRng);Redis 里 key 用 `hash_credential(session_id)` 摘要化(`auth/store.rs:135`),**Redis dump 也拿不到可用的 session id**;TTL 走 `EX`(`store.rs:105-113`),logout/delete 可吊销。会话生命周期整体没有短板。 +- **会话 cookie 属性齐全**:`Secure; HttpOnly; SameSite=Lax`(`api/admin/auth_routes.rs:24`),配合无 CORS 天然防跨站。 +- **登录锁定机制本身存在**:5 次/15 分钟(`auth/service.rs:17-19`)——防线在,只是被 #3 的 IP 伪造绕过;修好信任边界后即有效。 +- **管理端接口全量鉴权**:抽查 system/accounts/keys/settings/usage/ops/dashboard 全部处理器都带 `_auth: AdminAuth` 提取器(`api/admin/session.rs:15-25` 统一入口),没发现漏挂鉴权的管理端点。 +- **OAuth 账号导入走 PKCE + state**:`fleet/manage/oauth.rs:42-48` 随机 32/64 字节 state/verifier + S256 challenge,`exchange` 校验 `session.state == state`(`oauth.rs:83-86`),session 有 TTL。 +- **SQL 全参数化**:grep `format!("…select/insert/update/where…")` 零命中,所有查询用 `sqlx::query(...).bind(...)`,无字符串拼接注入面。 +- **容器非 root**:`deploy/Dockerfile:54,73,83` 以 `cpr`(uid 10001, nologin)运行,产物 `--chown` 到该用户。 +- **config 不进日志**:`AppConfig` 虽 `derive(Debug)` 但全仓未发现打印/`{:?}` 记录,secrets 不会因日志泄露。 +- **`.runtime/config.yaml` 已 gitignore**:`git check-ignore` 确认被忽略,示例配置 `deploy/config.example.yaml` 不含真实凭据。 + +## 核实过的误报 + +- **"CORS 配置过宽"**:实际**完全没有 CORS 层**(grep 零命中)。对 cookie 鉴权的 admin API 而言,"不允许任何跨源" + `SameSite=Lax` 是**安全默认**,不是缺陷。不用加 CORS,除非将来要做跨域前端。 +- **"client API key 验证有逐字节比较时序 oracle"**:如 P0-#2 所述,比较委托给 Postgres 索引点查,且 key 为 256-bit 高熵,时序侧信道实际不可利用。真问题是明文 at-rest,不是时序。 +- **"account 导出接口泄露 token"**:`fleet/manage/export.rs` 确实以明文 JSON 导出 token,但这是 admin 鉴权后的**预期导出功能**,属"admin 被攻破即等同 token 外泄"的固有信任假设,非越权泄露。 + +## 建议动手顺序 + +1. **P1 第 3 条(IP 信任边界)先做**——匿名可直接利用、危害是"唯一爆破防线失效",且改动集中在 `request_id.rs` 一处 + 锁定加一个非 IP 维度,投入产出最高。 +2. **P0 第 2 条(client key 摘要化)**——有 admin key 的现成范式可抄,改 `keys/store.rs` 生成/查询 + 建一个数据迁移(旧明文 key 转摘要),同时收敛管理端明文回显。 +3. **P0 第 1 条(上游 token 加密)**——改动面比 #2 大(加解密边界 + 密钥管理 + 迁移),但资产价值最高;建议应用层 AEAD,密钥走环境变量/KMS。 +4. **P1 第 4 条(自更新签名)**——供应链加固,需引入签名产物与内嵌公钥,配合发布流程改造,单独排期。 +5. **P2 第 5、6、7、8 条**——安全响应头、body limit、错误脱敏、默认密码文档化,按精力顺手清理(第 6 条先确认缓冲行为)。 diff --git a/docs/sqlite-postgres-upgrade-regression-2026-07-12.md b/docs/sqlite-postgres-upgrade-regression-2026-07-12.md new file mode 100644 index 000000000..020ab502f --- /dev/null +++ b/docs/sqlite-postgres-upgrade-regression-2026-07-12.md @@ -0,0 +1,116 @@ +# SQLite v3 到 PostgreSQL 升级回归(2026-07-12) + +## 1. 测试环境 + +- 最终镜像:`codex-proxy-rs:real-test-20260712` +- 镜像 ID:`sha256:308ef0e1c6dac2f83a2de30bebe85ead32b779913011c6a7bbcfc71d1aea412e` +- Compose 项目:`cpr-sqlite-upgrade-20260712` +- 隔离服务:`http://127.0.0.1:18083` +- SQLite 源库:`.runtime/sqlite-upgrade-20260712/source.sqlite`,权限 `0600` +- PG/Redis 使用独立 Docker volume,与真实账号回归环境完全隔离 + +源库按 SQLite v3 权威 Schema 生成,`schema_migrations.max(version)=3`。测试数据包含: + +- 管理员 1、客户端 key 1、运行时设置 1; +- 账号 2,其中 active 1、历史瞬态 refreshing 1; +- Cookie 1,且故意使用无法解析的过期时间; +- usage 混合事实 3:成功 1、错误 1、噪音 1; +- 独立 ops 错误 1、历史时间桶 1; +- `account_model_usage`、管理员会话、session affinity、刷新租约、模型快照各 1。 + +管理员密码哈希使用有效 Argon2 值,客户端 key 使用 SQLite v3 真实生成规则 `sk_`。 +报告不记录密码、哈希、完整 key、账号 token、Cookie 值或任何真实账号标识。 + +## 2. 实际升级命令 + +先只启动空的 PG/Redis,再通过最终镜像执行正式子命令: + +```bash +docker compose --env-file deploy/.env -f deploy/docker-compose.yml up -d postgres redis +docker compose --env-file deploy/.env -f deploy/docker-compose.yml run --rm --no-deps \ + -v "$(realpath /path/to/legacy.sqlite):/tmp/legacy.sqlite:ro" \ + codex-proxy-rs codex-proxy-rs import-sqlite /tmp/legacy.sqlite +``` + +`docker compose run ... import-sqlite` 会覆盖镜像 `CMD`,使 `tini` 尝试执行不存在的 +`import-sqlite` 文件;Docker 场景必须显式写出容器内二进制 `codex-proxy-rs`。 + +## 3. 导入报告 + +正式命令退出码为 0,报告如下: + +| 分类 | 项目 | 数量 | +| --- | --- | ---: | +| 导入 | `account_cookies` | 1 | +| 导入 | `accounts` | 2 | +| 导入 | `admin_users` | 1 | +| 导入 | `client_api_keys` | 1 | +| 导入 | `ops_error_logs` | 2 | +| 导入 | `request_time_buckets` | 1 | +| 导入 | `runtime_settings` | 1 | +| 导入 | `usage_records` | 1 | +| 规范化 | `accounts.refreshing_to_expired` | 1 | +| 丢弃 | `account_model_usage` | 1 | +| 丢弃 | `account_refresh_leases` | 1 | +| 丢弃 | `admin_sessions` | 1 | +| 丢弃 | `model_plan_snapshots` | 1 | +| 丢弃 | `session_affinities` | 1 | +| 丢弃 | `usage_record_noise` | 1 | +| 降级 | Cookie 过期时间解析失败 | 1 | + +## 4. PostgreSQL 数据核对 + +- PG 迁移为 `1 / initial`,checksum 长度 64。 +- 账号为 active 1、expired 1;`refreshing=0`。 +- 原 refreshing 账号已经变为 expired,且 `next_refresh_at is null`。 +- 完整客户端 key 与 prefix 原值保留,空 key 数为 0,迁移后 Bearer 鉴权成功。 +- 管理 API key 只保存哈希,旧明文通过 `x-api-key` 验证成功。 +- 运行时设置保持 smart、并发 3、请求间隔 50 ms;保留期补齐为 30/30/90 天。 +- Cookie 行保留,坏过期时间按设计降级为 `expires_at is null`。 +- 成功事实提升字段正确:requested/upstream model、service tier、首字延迟及 12/5/3/2 token。 +- ops 最终为 2 条,状态分别为 429、502。 +- 所有运行态表均未进入 PG,Redis 启动前为空。 + +## 5. 桶重建 + +执行正式 `rebuild-buckets`: + +```text +deleted=1, rebuilt=2 +``` + +重建后汇总为:成功 1、错误 2、input 12、output 5、cached 3;与拆分后的 +`usage_records` 和 `ops_error_logs` 完全一致,不再沿用 v3 混合桶的旧口径。 + +## 6. 运行链路 + +完成迁移核对后,将唯一 active 的合成账号临时改为 disabled,避免假 token 请求真实上游; +此操作发生在迁移结果验收之后,不属于迁移逻辑。 + +| 检查 | 结果 | +| --- | --- | +| `/healthz` | 204 | +| 管理员密码重新登录 | 200,Redis 创建新会话 | +| 旧管理 API key | `x-api-key` 返回 200 | +| 旧客户端 key | `/v1/models` 返回 200,8 个模型 | +| 账号列表 | 2 行,分页 total=2 | +| 客户端 key 列表 | 1 行,完整 key 可取回且 enabled | +| usage 列表 | 1 条成功事实,状态 200 | +| ops 列表 | 2 条错误事实,状态 429/502 | +| Dashboard summary | 200,统计与 smart 策略可读取 | +| Settings | smart、3600/2/3/50 均与 v3 一致 | +| Redis 运行态 | 只有新管理员会话;affinity/lease/model snapshot 均为 0 | + +## 7. 边界与持久化 + +- 对同一目标库再次执行导入,命令以 `TargetNotEmpty` 失败,所有表行数保持不变。 +- 自动化测试同时覆盖未知源版本、坏时间戳全事务回滚和空管理 API key。 +- 强制重建应用、PG、Redis 容器但保留 volume 后,账号 2、key 1、usage 1、ops 2、bucket 2、Cookie 1 全部保留。 +- 重建后旧管理员会话仍有效,旧客户端 key 仍能访问 `/v1/models`。 +- 最终三个容器均 healthy;应用日志只有 INFO,PG/Redis warning/error 均为 0。 + +## 8. 结论 + +SQLite v3 到 PG v1 的一次性升级链路可以正常使用,数据变换、显式丢弃、桶重建、 +管理端读取、旧 key 鉴权和容器持久化均符合 `database.md`。Docker 用户必须使用文档中的 +显式二进制命令,并严格遵守“先导入、后启动应用”的顺序。 diff --git a/docs/storage-review.md b/docs/storage-review.md new file mode 100644 index 000000000..a42ee7666 --- /dev/null +++ b/docs/storage-review.md @@ -0,0 +1,105 @@ +# 存储层审查(backend) + +> 审查于 `feat/postgres-redis-migration` 工作树(fleet 改名之后)。覆盖 `infra/{database,migrations,redis}.rs` 连接与迁移、`fleet/store`、`telemetry/{usage,ops,buckets,account_usage}`、`keys/store`、`settings/store`、`fleet/cookies`、`upstream/openai/fingerprint/store`、四个 Redis 存储(session/lease/affinity/models)、`bootstrap/import_sqlite`、保留期 trim 任务与 `deploy/docker-compose.yml`。对照基准是 `docs/database.md`(唯一权威文档)。 + +先说结论:实现与 database.md 的契约**高度一致**——终态 DDL 逐字落地、事实+桶同事务、Redis 互斥走 Lua、keyset 分页配套索引、测试断言 schema。担心的"迁移演进不存在"是误判:自研迁移框架有版本表、幂等、单事务、防降级,加列 = 往 `MIGRATIONS` 数组追加一项。真正的问题集中在三类:**第一次 schema 演进时的流程脆弱点**(P0,常量漂移会造成确定性启动失败)、**多步写缺事务**(P1,账号导入)、**写而不读的表和索引**(P1/P2,白付写放大)。 + +## Redis 键全景(盘点) + +前缀统一 `cpr:`(`bootstrap/services.rs:469` 传入,`infra/redis.rs:49-54` 规范化;测试用随机前缀)。与 database.md §4B 逐键比对无缺漏、无文档外的键: + +| 键 | 结构 | TTL | 位置 | +| --- | --- | --- | --- | +| `cpr:admin:session:` | String(JSON) | `SET EX`,默认 1440 分钟(`config.rs:300`),登出 DEL,不滑动续期 | `auth/store.rs:94-136` | +| `cpr:lease:refresh:` | String(owner) | `SET NX PX` + Lua 同 owner 续约/比较释放 | `fleet/refresh/lease.rs:49-87` | +| `cpr:affinity:resp:` | String(JSON) | `EXAT created_at+4h` | `dispatch/affinity/store.rs:56-61` | +| `cpr:affinity:conv:` | ZSET | `EXPIRE 4h` 每写刷新 + `ZREMRANGEBYSCORE` 惰性剪枝 | `store.rs:63-84` | +| `cpr:affinity:account:` | SET | `EXPIRE 4h` 每写刷新,**成员无剪枝**(见 P2-7) | `store.rs:72-79` | +| `cpr:models:plan_snapshots` | HASH(field=plan_type) | 无 TTL,HSET 最新态覆盖(field 只增不删,见 P2-8) | `models/store.rs:62-99` | + +无 TTL 的只有模型缓存 HASH(文档许可的"最新态覆盖"形态);不存在游离的永久 String 键。命名全部符合 `cpr:<域>:<实体>:<键值>` 规范。 + +## P0 — 正确性风险(第一次迁移演进就会踩) + +### 1. `CURRENT_SCHEMA_VERSION` 与 `MIGRATIONS` 手工同步——漏改的后果是"上线成功、重启失败" +- 现状:`infra/database.rs:7` 的 `const CURRENT_SCHEMA_VERSION: i64 = 1` 与 `:8-12` 的 `MIGRATIONS` 数组是两个独立的手工维护点;`validate_applied_migrations`(`database.rs:82-99`)在**每次启动、应用迁移之前**检查 `applied.last() > CURRENT_SCHEMA_VERSION` 即报错。database.md §4.1 只用一句"与迁移列表同步提交"约束这个同步。 +- 问题:推演"上线后加一列"的标准动作——追加 `Migration { version: 2, ... }` 但忘了把常量改成 2:**第一次启动完全正常**(校验时 applied=[1] 通过,随后应用 v2 并落表);**第二次启动必然失败**(applied=[1,2],last=2 > 1 → `unsupported PostgreSQL schema version 2`),从此 boot loop,直到改代码重新发版。故障错开一个重启周期出现,极难在发布验证中发现;Docker `restart: unless-stopped` 会把它放大成持续崩溃重启。没有任何测试守护这个同步(`tests/infra/database.rs` 只断言 0001 的结果)。 +- 建议:删掉手工常量,派生之——`MIGRATIONS.last().map(|m| m.version)`;或至少加 `const _: () = assert!(...)` / 单元测试断言两者一致。一行改动消灭整类事故。 + +## P1 — 正确性风险 + +### 2. 已应用迁移无 checksum,"已发布迁移永不修改"只靠自觉 +- 现状:`schema_migrations` 只记 `(version, name, applied_at)`(`database.rs:121-134`),不存 SQL 摘要;`migrate()` 对已应用版本直接 skip(`database.rs:47-50`)。sqlx 自带的 `migrate!` 宏有 checksum 校验,本项目自研框架没有。 +- 问题:有人事后编辑 `0001_initial.sql`(哪怕只是"顺手修个注释里的列名"),存量库与新装库从此走上两条 schema,且**永远不会报错**。这类漂移只有在某条查询炸掉时才暴露。 +- 建议:`Migration` 增加 `sql` 的 SHA-256,应用时落库、启动时对已应用版本比对,不匹配即拒绝启动。顺手把"迁移演进测试模板"补上:一个从 v1 库跑 `migrate()` 到 v2 并断言列存在的测试,给未来第一条真实迁移打样。 + +### 3. 账号导入的多步写不在事务里 +- 现状:`fleet/manage/import.rs:190-211` 新账号路径是三条独立语句:`store.insert(account)` → `store.set_next_refresh_at(...)` → `store.apply_imported_quota_state(...)`;`:170-189` 更新路径同样是 `update_from_import` → `set_next_refresh_at` 两步。每步失败都映射成 `AccountManageError::Import` 返回,但已执行的写不回滚。全仓只有 5 处 `begin()`(usage append、ops append、bucket rebuild、migrate、import_sqlite),账号域一处都没有。 +- 问题:中途失败留下半初始化账号——最典型是 insert 成功、quota 状态未落:`quota_verify_required` 丢失意味着下一轮调度不会强制校验配额,导入进来的 quota_exhausted 账号可能被当健康账号用。`next_refresh_at` 丢失可由 token 刷新调度器自愈(`refresh/service.rs:234` 会 persist),quota 标志则要等周期 quota refresh 兜底,窗口内行为不对。 +- 建议:给 `PgAccountStore` 加一个接受 `&mut Transaction` 的导入写路径(或直接把三条 SQL 合成一条带 CTE 的语句);import 单账号 = 单事务。同类小问题:`account_usage/store.rs:513-554` 的 `update_rate_limit_window` 是"SELECT 判断 → 二选一 UPDATE"的 read-then-write,两个并发窗口同步可能双双判为不重置;单进程 + 每账号串行调度下概率极低,顺手改成单条 SQL(把 `should_reset_usage_window` 判定内联进 `case when`)即可闭环。 + +### 4. `account_model_usage` 全库零读者——每请求白付一次 upsert +- 现状:写路径活跃——每次槽位释放/用量观测各一次 `RECORD_MODEL_USAGE_SQL` upsert(`telemetry/account_usage/store.rs:49-60`,由 `fleet/pool/mod.rs:218-236,389-409` 调用);但**全仓没有任何 SELECT 读它**(唯一出现处是 import_sqlite 的搬迁写入)。账号详情页的"当前窗口模型分布"实际走的是 `request_time_buckets` 的 `model_usage_by_windows`(`api/admin/accounts_routes/query.rs:311-316` → `telemetry/buckets/query.rs:203-236`)。database.md §4.7 宣称的两个读者(详情页、调度辅助 `error_count` 降权)在代码里都不存在。 +- 问题:热路径上每请求多一次 upsert + `idx_account_model_usage_last_used` 索引维护,收益为零。更麻烦的是它按 §2.10 属"不可重建"数据——现在不决定,数据继续积累,以后退役的沉没成本只会更高。 +- 建议:二选一并同步改文档——(a)把详情页"生命周期模型分布"接到这张表上(buckets 只有 90 天,两者口径本就不同);(b)确认没有该需求,删表删写路径。这是产品口径决策,不是纯技术项,但**保持现状是最差选项**。 + +## P2 — 性能与纪律 + +### 5. 四枚无读者索引,违反 §2.8"每个索引对应一条真实查询" +- `idx_accounts_status`(`0001_initial.sql:63`):没有任何查询按 status 过滤 accounts——池加载是全表 `LIST_POOL_ACCOUNTS_SQL`(`fleet/store/queries.rs:9-16`),状态过滤全在内存(`dashboard_routes.rs:315-335`)。 +- `idx_request_time_buckets_model`(`:237-238`):三条 bucket 查询(`buckets/query.rs:27,161,221`)的谓词分别是 bucket_start 范围、account_id IN + 范围、`model != '__unknown__'`(不等式),没有一条能把它当驱动索引。**这是全库最热写入表**,每请求 upsert 都在维护它。 +- `idx_account_usage_last_used_account`(`:100-101`):唯一按 last_used_at 排序的查询用的是 `coalesce(au.last_used_at, 'epoch')` 表达式(`account_usage/store.rs:570-580`),普通列索引匹配不上;表本身每账号一行,量级无所谓。 +- `fingerprint_update_history` 两枚(`:284-287`):该表也是只写不读(仅 `fingerprint/store.rs:147-172` insert)——审计表可以接受无代码读者,但 `created_id` 这种 keyset 分页索引显然为不存在的列表 API 预建,与"不预建可能有用"的纪律冲突。 +- 建议:删 `idx_accounts_status` 与 `idx_request_time_buckets_model`(后者有真实写收益);`idx_account_usage_last_used_account` 要么删、要么改成 `coalesce(...)` 表达式索引与查询对齐;fingerprint 两枚随表的去留一起定。 + +### 6. 错误桶的 service_tier 维度:live 写入与 rebuild 口径不一致 +- 现状:`upsert_error` 把内存里的 `event.service_tier` 当维度写桶(`buckets/store.rs:116`,recorder 会从 metadata 提升它,`recorder.rs:250-253`),但 `ops_error_logs` **没有 service_tier 列**(读取时硬编码 `service_tier: None`,`ops/store.rs:314`);`rebuild_reconstructible_range` 重算时错误行只能归 `'__unknown__'`(`buckets/store.rs:207`)。 +- 问题:同一批错误,live 写入落在 `(model, 'priority')` 桶,rebuild 后并进 `(model, '__unknown__')` 桶——总量守恒但维度分布漂移,`rebuild-buckets` 的"验收 = 聚合值一致"对 error_count 分维度不成立。 +- 建议:要么 `upsert_error` 也统一传 `None`(错误事实本就不承载计费维度,最小改动);要么给 ops_error_logs 增列(§4.9 预留清单路径)。前者更符合现有文档口径。 + +### 7. `cpr:affinity:account:` SET 活跃期内只增不减 +- 现状:每个成功响应 `SADD` 一个 response_id 且 `EXPIRE` 刷新 4h(`dispatch/affinity/store.rs:72-79`);conv ZSET 每写有 `ZREMRANGEBYSCORE` 剪枝(`:80-84`),account SET 没有等价物;`record_response_affinity` 记录新响应时也不 forget 前链(`dispatch/affinity/resolve.rs:170-222`,forget 只在 implicit-resume 恢复路径出现)。 +- 问题:持续活跃(写间隔 < 4h)的账号,SET 成员数 ≈ 自上次 4h 空闲以来的全部成功响应数。自托管场景每天总有空闲窗清零,内存上界 ≈ 单账号单日响应量(几 MB 级),不致命;但删除账号时 `forget_account` 是 SMEMBERS 后逐成员 GET+pipe(`store.rs:171-181`),几万成员就是几万次往返。 +- 建议:低成本版——upsert 时对 account 键也做惰性剪枝(SET 换 ZSET,score=createdAt,复用 ZREMRANGEBYSCORE);或维持现状但在文档 §4B.3 注明"活跃期上界=空闲窗内响应数"这个诚实边界。 + +### 8. 小件 +- **`cpr:models:plan_snapshots` field 只增不删**:plan_type 从池中消失后其快照 field 永久残留(`models/store.rs:62-81` 只 HSET),`list_plan_snapshots` 会继续返回。基数=历史上出现过的套餐种类,无害,但严格说不满足"最新态覆盖"的完整语义;账号池側有 plan 过滤兜底。 +- **页码分页 = OFFSET + count(\*)**:`list_page` 系列(`usage/store.rs:172-199`、`ops/store.rs:114-141`、`fleet/store/mod.rs:487-517`)每次视图先全量 `count(*)` 再 OFFSET。30 天保留期封顶 + 仅管理端,现阶段无感;量级上来的出路是文档已定的"缩短保留期",不必预优化。 +- **PG 池参数硬编码**:`max_connections(10)`/`acquire_timeout(5s)`(`infra/database.rs:22-24`)与 database.md §1 一致,但 idle_timeout/max_lifetime 吃 sqlx 默认(10min/30min),且全部不可配置。单实例自托管可接受;若未来遇到网络中间件掐空闲连接,记得这里。Redis 侧 ConnectionManager 默认重连参数(`infra/redis.rs:14-21`),与文档"单条多路复用连接"一致。 +- **`to_page` 静默吞行**:`fleet/store/rows.rs:231-239` 与 `account_usage/store.rs:773-798` 对 map 失败的行 `if let Ok` 跳过,坏行只会让页短一截而无日志;status 有 check 约束兜底,概率极低,加个 warn 即可。 + +### 9. import_sqlite 退役与 feature 裁剪(评估结论:可以动手) +- 现状:sqlite 代码只有 4 个文件触达——`main.rs:11-23` CLI 入口 + `bootstrap/import_sqlite/{mod,core,telemetry}.rs`;`Cargo.toml:34` 的 sqlx 开着 `sqlite` 与 `uuid` 两个 feature,后者**全仓零使用**(UUID 一律以字符串 bind,`fleet/cookies/store.rs:141`)。 +- 退役条件已天然满足:导入要求目标库零业务行(`import_sqlite/mod.rs:149-173`),而 `serve` 首次启动就会写入 runtime_settings/admin_users——**任何已运行过的部署都永久过了导入窗口**,这条命令对存量实例已不可用,只服务"还没搬迁的旧 SQLite 用户"。 +- 建议:分两步。现在就删 `uuid` feature(零成本);`sqlite` feature + import 模块用 cargo feature(如 `import-sqlite`)门控,默认不编,发布说明标注"需要搬迁请用 vX.Y";一两个版本后整体删除,历史留在 git。收益:少编译一整个 SQLite 静态库,砍掉 sqlx 双驱动的编译面。 + +## 做得好的(记录一下,免得反复怀疑) + +- **事实+桶同事务,契约兜底**:成功路径 `usage/store.rs:84-128`、失败路径 `ops/store.rs:48-87` 都是 `begin() → insert 事实 → upsert 桶 → commit`,与 §5.2 逐字一致;0ms 延迟样本合法(check `>= 0`)、min/max 用 nullable-safe 的 case/`greatest()`(`buckets/store.rs:69-74`)。 +- **迁移框架的防御做对了**:整批未应用迁移跑在单事务里(PG DDL 事务性,全成或全无,`database.rs:37-56`);拒绝无版本存量库(`:101-119`);拒绝库版本高于二进制(防旧版回滚踩新库);`schema_migrations` 由建库路径而非迁移文件创建。加列的机制是存在且幂等的——P0 只是常量同步这一个点。 +- **import_sqlite 是教科书式一次性搬迁**:源库只读打开、schema v3 强校验、目标非空拒绝、全程单 PG 事务、逐表导入/规范化/丢弃三本账(`import_sqlite/mod.rs:112-137`)。 +- **Redis 互斥纪律**:lease 获取 `SET NX PX` + 同 owner Lua 续约、释放 Lua 比较 owner(`lease.rs:49-87`),批查用 MGET(`:104-107`);没有 GET-判断-SET 三段式。亲和写入 MULTI pipe + conv 惰性剪枝,与 §4B.3 一致。 +- **索引与热查询整体匹配**:usage_records 8 枚索引与 `UsageRecordFilter` 字段一一对应(`usage/store.rs:284-346`),可空维度全部部分索引;keyset 分页统一 `(created_at desc, id desc)` 配复合索引;鉴权是 `key` unique 点查(`keys/store.rs:50-60`)。热查询路径没有任何 `->>` JSON 聚合(quota_json 只在单行读取时解析)。 +- **last_used_at 去抖**:1s 批量聚合后单条 `unnest` UPDATE(`keys/service.rs:18-104`、`keys/store.rs:192-213`),失败回灌重试,不在请求关键路径。 +- **池恢复无 N+1**:`restore_from_store` 恰好两条查询——账号全表 + `snapshots(ids)` 批查(`fleet/pool/mod.rs:133-160`);email 映射、bucket 窗口聚合也都是 IN/unnest 批量(`usage/query.rs:371-401`、`buckets/query.rs:130-201`)。 +- **trim 是周期任务**:每小时读 runtime_settings 三列后各删一次(`retention_trim.rs:16,49-69`),v3 的内联写放大确认已移除;`rebuild-buckets` 用 `lock table ... share row exclusive` 保证重算窗口一致性(`buckets/store.rs:148-153`)。 +- **健康检查双活**:`/healthz` = PG `select 1` + Redis `PING`(`bootstrap/state.rs:31-41`);compose 里 postgres:18-alpine / redis:8-alpine(AOF 开启)带 healthcheck 且应用 `service_healthy` 依赖。 +- **测试基建**:每测试独立数据库 + `migrate()` 真跑 + 随机 Redis 前缀(`tests/support/storage.rs`);`tests/infra/database.rs` 断言终态表集、约束、关键索引、凭据存储形态。 + +## 核实过的误报 + +- **"account_usage 不与事实同事务是漏洞"**——不是。§5.2 显式修订过:调度器持久化时点独立、每笔 upsert 自身原子、允许亚秒漂移,对账以事实表为准。代码(`fleet/pool/mod.rs:189-237`)与文档一致。 +- **"窗口计数双写冲突"**——`RECORD_USAGE_SQL` 增量与 `SYNC_RUNTIME_WINDOW_SQL` 覆盖确实都写 window_* 列,但内存池是权威源,同步带 reset_at 单调守卫(`account_usage/store.rs:81-83`),漂移在文档允许范围内。 +- **"live 桶用代码槽对齐、rebuild 用 SQL 槽对齐,两者会漂"**——`china_quarter_hour_start`(`infra/time.rs:117-130`)按中国时区截断到 15 分钟,+08:00 是整小时偏移,与 `floor(epoch/900)*900` 数学等价,§2.3 的论证成立。 +- **"`AccountStore::get_pool_account` 默认实现全表加载"**——trait 默认实现(`fleet/store/mod.rs:236-241`)确实是 list 后 find,但 `PgAccountStore` 覆写为点查(`:321-325`);默认实现只有测试 fake 在用。 +- **"quota/token 刷新每轮全表拉账号是 N+1"**——是一条全表查询 + 内存过滤(`fleet/quota/runtime.rs:114-134`、`fleet/refresh/service.rs:197-256`),账号表常量级,合理。 +- **"forget_account 循环是 N+1"**——SMEMBERS 后逐条 forget 是 §4B.3 设计原文;真正的问题在 SET 增长(P2-7),不在循环本身。 +- **"迁移并发竞争"**——两进程同时 migrate 时,后者会在 `record_migration` 的 PK 冲突上回滚整个事务而失败退出,不会写坏库;单实例部署 + compose 依赖下可接受,无需 advisory lock。 + +## 建议动手顺序 + +1. **P0 第 1 条**(派生 `CURRENT_SCHEMA_VERSION` + 一致性断言)先做——一行改动,消灭"第一次加列就翻车"的确定性事故;顺手把 **P1 第 2 条**的 checksum 和迁移测试模板一起交付,凑成一个"迁移框架加固" commit。 +2. **P1 第 3 条**(导入事务化 + rate-limit 窗口单语句化)——正确性,改动局部在 `fleet/store` 与 `account_usage/store`。 +3. **P1 第 4 条**(account_model_usage 定去留)——先开口径讨论再动代码;结论落进 database.md。 +4. **P2 第 5、6 条**一起做(删两枚无读者索引 + 错误桶 service_tier 统一)——都要动 0001 基线之外的第一条真实迁移,正好用上第 1 步打好的框架。 +5. P2 第 9 条(feature 裁剪)随下个发布窗口;其余 P2 视精力。 diff --git a/docs/test-coverage-review.md b/docs/test-coverage-review.md new file mode 100644 index 000000000..675bb1417 --- /dev/null +++ b/docs/test-coverage-review.md @@ -0,0 +1,94 @@ +# 测试覆盖审查(backend) + +> 审查于 `feat/postgres-redis-migration` 工作树。对象:`backend/tests`(32,961 行、612 个测试函数,单入口 `tests/main.rs`)对照 `backend/src`(42,775 行)。数字均为实测:`wc -l`、`rg -c '#\[(tokio::)?test'`、`rg '#\[cfg\(test\)\]' src/ | wc -l`。 + +先说结论:这是一套**质量高于平均水平**的纯集成测试。灾难恢复(401/402/403/404/429/5xx 降级链)、WS 断连/超时/复用、token refresh 失败矩阵、self-update 安全属性——这些最容易偷懒的路径反而是全仓最密的,断言是真行为断言而非"没 panic"。真正的盲区集中在三处:**存储故障注入(PG/Redis 运行中不可用)全仓零覆盖**、**真并发争用测试为零**(89 处锁原语全靠顺序测试)、telemetry 的直接测试极薄(0.06 行比)。测试基建(每测试独立 PG 库 + Redis 前缀隔离)是并行安全的,值得保持。 + +**覆盖热力图**(直属 = `tests/<同名目录>`;多数子系统另有经 `tests/api` 的间接覆盖): + +| 子系统 | src 行 | 直属测试行 | 测试数 | 行比 | 间接覆盖 / 备注 | +|---|---|---|---|---|---| +| fleet | 9,946 | 5,003 | 123 | 0.50 | + `api/admin/accounts_routes` 50 个(manage/import/probe/oauth 走 API) | +| upstream | 7,873 | 6,727 | 148 | 0.85 | protocol/transport 双层都测 | +| dispatch | 6,140 | 9,349 | 120 | **1.52** | 全仓最密,恢复/WS/SSE/用量四大块 | +| api | 5,691 | 8,860 | 157 | 1.56 | 含挂在此处的 system/dashboard 等跨子系统测试 | +| telemetry | 4,784 | 278 | 6 | **0.06** | + dashboard 19、usage_routes 4、dispatch/usage_logging 16 | +| bootstrap | 3,209 | 1,408 | 26 | 0.44 | tasks 半数无测试(见 P1-4) | +| update | 1,767 | 0 | 0 | — | `api/admin/system_routes` 23 个端到端,覆盖充分(见误报) | +| infra | 1,008 | 408 | 18 | 0.40 | identity/json/redis 无直测(小件) | +| models | 853 | 340 | 8 | 0.40 | + client/models 4、model_refresh 任务测试 | +| keys | 522 | 127 | 4 | 0.24 | + keys_routes 5、client auth 6 | +| settings | 476 | 0 | 0 | — | api/settings_routes 14 个,够用 | +| auth | 376 | 64 | 2 | 0.17 | + auth_routes 7;logout/过期未测(见 P1-5) | + +**测试类型分布与取舍**:src 内 `#[cfg(test)]` 为 **0**——没有任何单元测试,612 个测试全部经 `cargo test --test main` 单二进制跑(417 个 `tokio::test`,其中 3 个 `start_paused`;195 个同步 `#[test]`,集中在 protocol 解析与 AccountPool 纯逻辑,实为"单元风格"测试放在集成树里)。这个取舍对本项目是**净收益**:测的是公开契约,所以 PG/Redis 大迁移(正是本分支)测试大面积存活;代价是私有纯函数(affinity 的 conversation key/variant hash 派生、`api/client/errors.rs` 分类)只能被间接锁定、故障注入受 trait 边界限制、改任何测试都全量重链接。不建议翻转架构,仅按 P2-9 对少数纯函数补直测。 + +## P0 — 灾难路径盲区 + +### 1. 存储故障注入零覆盖:PG/Redis 运行中不可用的"降级契约"从未验证 +- 现状:全仓测试都假设 PG/Redis 健康(每测试新建库 + 真连接);`rg` 全 tests/ 找不到一处模拟 Redis 断连或 PG 写失败。 +- src 侧的设计是 warn-and-continue:affinity 记录失败只 `warn!`(`dispatch/affinity/resolve.rs:211`)、usage/ops 落库失败只 `warn!`(`dispatch/recording.rs:181,248,540`)——即"遥测/亲和失败不拖垮请求"。**这个契约没有任何测试锁定**:未来有人把 `if let Err` 改成 `?`,Redis 一抖动就是全线 500,现有套件全绿。 +- 同类未验证路径:refresh lease(`fleet/refresh/lease.rs`)Redis 失败时 token refresh 是跳过还是重复刷新;admin session store 失败时管理台是 401 还是 500;dispatch 中途 PG 断开时流是否照常完成。 +- 可测性:`AccountStore`/`AccountUsageStore`/`ModelSnapshotStore` 已是 trait(`fleet/store/mod.rs:231` 等),注入失败实现零成本;`SessionAffinityService` 直接持有 Redis 具体类型(`dispatch/service.rs:91`),需要断连式模拟(连已关闭端口/中途 kill 连接)或补一层 trait。 +- 建议:先做 trait 注入版——"PG usage 写失败,响应仍 200 且有 warn 日志";再做 Redis 断连版锁 affinity/lease/session 三条路径。 + +### 2. 真并发争用测试为零:89 处锁原语全靠顺序测试背书 +- 现状:全 tests/ 无一处 `join_all`/`tokio::join!`/`FuturesUnordered`;`fleet/pool/selection.rs` 的 32 个测试全部是**同步顺序** acquire/release;`smart_should_spread_concurrent_requests_by_slot_pressure` 名字带 concurrent,实际是顺序模拟。唯一的真双请求 in-flight 测试是 `responses_should_stagger_same_account_requests_before_sending_upstream`(oneshot 编排,范本级,但只此一个)。 +- 风险面:AccountPool slot 记账(泄漏 = 账号永久卡死)、websocket_pool 的 serial-per-conn/bypass 语义在并发 borrow 下的正确性、scheduler feedback EMA 交错写、`reasoning_replay` 的 `Arc`(`dispatch/service.rs:92`)。这些是代理的核心价值路径,顺序测试原理上测不出 race。 +- 建议:N 任务 `join_all` 压 acquire/release,断言不变量(全释放后 slot 归零、同账号并发不超 cap、无重复借出);WS pool 并发 borrow 同 key,断言 serial-per-conn 与 bypass 计数。不必上 loom,多线程 runtime + 不变量断言即可。 + +## P1 — 覆盖薄弱区 + +### 3. telemetry:4,784 行 src 只有 278 行直测(行比 0.06) +- `account_usage/store.rs`(798 行)**零直接 store 测试**,只经 dashboard/quota 测试间接写读;`usage/query.rs`(725 行)+ `api/admin/usage_routes.rs`(671 行)只有 4 个路由测试——过滤器组合(failureClass/search/时间窗)、游标翻页边界、非法参数基本没测。`recorder.rs`(421 行)经 usage_logging 间接覆盖,但 `record_error` 自身失败分支无测试(与 P0-1 重叠)。 +- 已有的间接覆盖不差:dashboard 19 个测试把 billing 计价断言到美元字符串(priority tier、cache read、long context),`usage_logging` 16 个测试锁了 dispatch→落库链路。薄的是**查询/过滤面**——这是管理台排障时最依赖的面,查询 bug = 排障时看到错误数据。 +- 建议:补 `PgAccountUsageStore` 直测 + usage_routes 过滤矩阵(每维度一正一负)。 + +### 4. bootstrap 后台任务半数无测试 +- 无直测:`tasks/periodic.rs`(105 行)、`tasks/retention_trim.rs`(70 行,**定时删数据**——store 层 trim 有 `usage_store_trims_by_runtime_retention` 兜底,但任务层触发/参数拼装无测试)、`tasks/cookie_cleanup.rs`(61 行)、`shutdown.rs`(92 行)。 +- `coordinator.rs` 仅 2 个测试,其一 `task_coordinator_should_shutdown_without_panicking` 是全仓罕见的"只断言没 panic"式测试。优雅停机不排水在飞请求、任务 panic 后 coordinator 是否重启/放弃,均无覆盖。 +- 已覆盖得好的:token_refresh(14 个,见"做得好的")、model_refresh、fingerprint_update、cleanup。 + +### 5. auth/keys 生命周期缺口 +- `logout`(`api/admin/auth_routes.rs:105` → `delete_session`)**零测试**;session 过期后请求被拒未测(TTL 只在 store 层断言了 `1..=300` 秒区间);password 路由仅 1 个测试。已测得好的:登录 throttle、client key 不能当管理密码、登录不写 usage record。 +- keys:`flush_pending_last_used`(last_used 批量回写)仅 store 级测试,dispatch 高频路径下 pending 累积/flush 时机无行为测试。 + +## P2 — 基建健壮性,视精力而定 + +### 6. flaky 隐患:websocket_pool 测试用真时钟贴边跑 +- `tests/upstream/openai/transport/websocket_pool.rs` 6 处真实 `sleep` 配毫秒级时序参数:`ping_interval 1ms + liveness 20ms` 后 `sleep(60ms)`(795-816 行)、`max_age 5ms` 后 `sleep(15ms)`(868-889 行)等。CI 负载高时页边距很紧。 +- 修法现成:同目录 `websocket.rs` 已用 `start_paused` 虚拟时钟(3 处);`fleet/quota.rs:724` 的 `wait_for_usage_requests`(deadline 轮询)也是好模板。推广其一即可。 + +### 7. Redis 测试键不清理 +- `TestDatabaseGuard` drop 时 force-drop PG 库(`tests/support/storage.rs`),但 `create_test_redis` 的 `cpr:test: