From 823df0f0f7ae65c46fba952fba7627c05421a6c4 Mon Sep 17 00:00:00 2001 From: Linghong Date: Fri, 31 Jul 2026 05:02:06 +0000 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E6=9D=A5=E6=BA=90=E8=AF=BB=E5=8F=96=E7=9A=84=E6=97=B6?= =?UTF-8?q?=E9=97=B4=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSS 读取收口为进程级共享客户端并设置连接与整体超时 完美像素处理预算改从请求入口起算以覆盖下载阶段 补齐共享客户端、预算起点与带界下载的回归断言 Co-Authored-By: Claude Opus 5 --- .../crates/api-server/src/editor_project.rs | 75 ++++++++++++++++--- server-rs/crates/api-server/src/state.rs | 38 ++++++++++ 2 files changed, 103 insertions(+), 10 deletions(-) diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 719d649b0..8f2dac4e0 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -4492,6 +4492,14 @@ pub async fn snap_editor_image_to_pixel_art( ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; let started_at = Instant::now(); + // 中文注释:处理预算必须从进入 handler 起算,而不是等下载完成后才起算。inline HTTP + // 请求的 RequestContext 默认没有 external_call_deadline(只有队列 worker 会设), + // 所以下载阶段此前完全落在 30s 之外:请求可以先挂在 OSS GET 上,再持着最多 32 MiB + // 排队等 CPU 许可,预算形同虚设。这里一次性派生绝对 deadline,下载与规整共用同一份。 + let processing_deadline = resolve_editor_pixel_art_processing_deadline( + started_at, + request_context.external_call_deadline(), + ); ensure_editor_reference_image_source_is_stable( payload.source_image_src.as_str(), "pixel-art-snapper", @@ -4549,14 +4557,18 @@ pub async fn snap_editor_image_to_pixel_art( .await?; let source_object_key = source.object_key; let asset_kind = source.asset_kind; - let source_image = - download_editor_persisted_image_object(&state, source_object_key.as_str()).await?; - validate_editor_pixel_art_static_raster(&source_image)?; - let snapped_image = snap_editor_pixel_art_strict( - Arc::new(source_image), - request_context.external_call_deadline(), + let source_image = download_editor_persisted_image_object_within_deadline( + &state, + source_object_key.as_str(), + processing_deadline, ) .await?; + validate_editor_pixel_art_static_raster(&source_image)?; + // 中文注释:传已派生的绝对 deadline 而不是原始 request_deadline。 + // resolve_editor_pixel_art_processing_deadline 取 min,所以内层拿到的仍是这一份, + // 下载耗掉的时间会如实从规整预算里扣除,而不是让规整重新获得完整 30s。 + let snapped_image = + snap_editor_pixel_art_strict(Arc::new(source_image), Some(processing_deadline)).await?; let (width, height) = image::load_from_memory(snapped_image.bytes.as_slice()) .map(|image| (image.width(), image.height())) .map_err(|error| { @@ -9010,7 +9022,10 @@ async fn read_editor_reference_image_object( expire_seconds: Some(EDITOR_REFERENCE_IMAGE_READ_EXPIRE_SECONDS), }) .map_err(|error| map_oss_error(error, "aliyun-oss"))?; - let mut response = reqwest::Client::new() + // 中文注释:共享客户端自带 connect / total 超时,GET 不再可能无界挂起; + // 顺带复用连接池,避免每次读参考图都重新做一次 TLS 握手。 + let mut response = state + .editor_oss_read_http_client() .get(signed.signed_url.as_str()) .send() .await @@ -9094,6 +9109,28 @@ async fn download_editor_persisted_image_object( }) } +// 中文注释:客户端级超时兜住所有调用方,但像素规整还要求下载本身计入 30s 处理预算。 +// 否则预算只从下载完成后起算:请求可以先花掉客户端的 60s 上限,再持着完整图片去排 +// CPU 许可的队,实际占用远超预算。这里用调用方给出的绝对 deadline 把整段 GET 收进 +// 同一个预算,两层保护各司其职——客户端超时是兜底,deadline 是本次请求的真实上界。 +async fn download_editor_persisted_image_object_within_deadline( + state: &AppState, + object_key: &str, + processing_deadline: Instant, +) -> Result { + tokio::time::timeout_at( + tokio::time::Instant::from_std(processing_deadline), + download_editor_persisted_image_object(state, object_key), + ) + .await + .unwrap_or_else(|_| { + Err(editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "读取待像素规整图片超时。", + )) + }) +} + fn normalize_editor_reference_image_mime_type(content_type: &str) -> Option<&str> { let mime_type = content_type.split(';').next()?.trim(); mime_type.starts_with("image/").then_some(mime_type) @@ -10630,15 +10667,19 @@ mod tests { "pub async fn snap_editor_image_to_pixel_art(", "async fn validate_editor_background_removal_source", &[ + // 中文注释:处理预算在任何 IO 之前派生,下载与规整共用同一份绝对 deadline; + // 一旦预算改回下载之后起算,下面的顺序断言会先失败。 + "resolve_editor_pixel_art_processing_deadline", "ensure_editor_reference_image_source_is_stable", "validate_editor_pixel_art_snap_canvas_completion", "serialize_editor_asset_metadata", ".get_editor_project", "validate_editor_pixel_art_snap_placeholder_exists", "resolve_editor_pixel_art_source_for_owner", - "download_editor_persisted_image_object", + "download_editor_persisted_image_object_within_deadline", "validate_editor_pixel_art_static_raster", "snap_editor_pixel_art_strict", + "Some(processing_deadline)", "persist_editor_generated_image", "persist_editor_generated_asset", "complete_editor_canvas_generation", @@ -10660,6 +10701,9 @@ mod tests { "external_generation_job", "queue_state", "snap_editor_pixel_art_or_original", + // 中文注释:下载必须走带 deadline 的包装。退回裸调用会让 OSS GET 重新 + // 落在 30s 预算之外,请求可以持着最多 32 MiB 无限期排队等 CPU 许可。 + "download_editor_persisted_image_object(&state", ], ); } @@ -14001,13 +14045,24 @@ mod tests { source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", - &["response.chunk().await", "bytes.extend_from_slice"], + &[ + "response.chunk().await", + "bytes.extend_from_slice", + // 中文注释:必须走进程级共享客户端。它自带 connect / total 超时, + // 是所有 OSS 读取调用方(含没有 deadline 可传的降级路径)的兜底上界。 + ".editor_oss_read_http_client()", + ], ); assert_function_not_contains( source, "async fn read_editor_reference_image_object", "async fn download_editor_persisted_image_object", - &["response.bytes().await", "bytes.to_vec()"], + &[ + "response.bytes().await", + "bytes.to_vec()", + // 中文注释:每次新建客户端会同时丢掉超时与连接复用,等于把无界 GET 放回来。 + "reqwest::Client::new()", + ], ); } diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 745819c6e..60a1008cc 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -272,6 +272,7 @@ pub struct AppStateInner { bgfilter_image_validation_limiter: Arc, character_animation_oss_http_client: reqwest::Client, character_animation_oss_io_limiter: Arc, + editor_oss_read_http_client: reqwest::Client, #[cfg(any())] creative_agent_executor: Arc, // Phase 1 任务 E 的 creative session facade 暂存在 api-server。 @@ -530,6 +531,7 @@ impl AppState { let character_animation_oss_http_client = build_character_animation_oss_http_client()?; let character_animation_oss_io_limiter = Arc::new(Semaphore::new(CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY)); + let editor_oss_read_http_client = build_editor_oss_read_http_client()?; let http_request_permit_pools = HttpRequestPermitPools::from_config(&config); let (profile_recharge_order_updates, _) = broadcast::channel(128); @@ -577,6 +579,7 @@ impl AppState { bgfilter_image_validation_limiter, character_animation_oss_http_client, character_animation_oss_io_limiter, + editor_oss_read_http_client, #[cfg(any())] creative_agent_executor: Arc::new(MockLangChainRustAgentExecutor), #[cfg(any())] @@ -1317,6 +1320,10 @@ impl AppState { self.character_animation_oss_io_limiter.clone() } + pub fn editor_oss_read_http_client(&self) -> &reqwest::Client { + &self.editor_oss_read_http_client + } + #[cfg(any())] pub fn creative_agent_executor(&self) -> Arc { self.creative_agent_executor.clone() @@ -2089,6 +2096,27 @@ fn build_character_animation_oss_http_client() -> Result Result { + reqwest::Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(60)) + .pool_idle_timeout(std::time::Duration::from_secs(300)) + .pool_max_idle_per_host(8) + .tcp_keepalive(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| { + AppStateInitError::DependencyUnavailable(format!( + "初始化编辑器 OSS 读取 HTTP 客户端失败:{error}" + )) + }) +} + fn build_wechat_client(config: &AppConfig) -> WechatClient { WechatClient::new(WechatConfig { app_id: config.wechat_mini_program_app_id.clone(), @@ -2236,6 +2264,16 @@ mod tests { ); } + #[test] + fn app_state_reuses_editor_oss_read_client() { + let state = AppState::new(AppConfig::default()).expect("state should build"); + + assert!(std::ptr::eq( + state.editor_oss_read_http_client(), + state.editor_oss_read_http_client(), + )); + } + #[test] fn bgfilter_image_validation_limiter_is_bounded_per_process_role() { let parent = AppState::new(AppConfig::default()).expect("parent state should build");