收紧完美像素来源读取的时间边界

OSS 读取收口为进程级共享客户端并设置连接与整体超时
完美像素处理预算改从请求入口起算以覆盖下载阶段
补齐共享客户端、预算起点与带界下载的回归断言

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 05:02:06 +00:00
parent 044f278aa3
commit 823df0f0f7
2 changed files with 103 additions and 10 deletions
@@ -4492,6 +4492,14 @@ pub async fn snap_editor_image_to_pixel_art(
) -> Result<Json<Value>, 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<DownloadedOpenAiImage, AppError> {
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()",
],
);
}
+38
View File
@@ -272,6 +272,7 @@ pub struct AppStateInner {
bgfilter_image_validation_limiter: Arc<Semaphore>,
character_animation_oss_http_client: reqwest::Client,
character_animation_oss_io_limiter: Arc<Semaphore>,
editor_oss_read_http_client: reqwest::Client,
#[cfg(any())]
creative_agent_executor: Arc<MockLangChainRustAgentExecutor>,
// 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<MockLangChainRustAgentExecutor> {
self.creative_agent_executor.clone()
@@ -2089,6 +2096,27 @@ fn build_character_animation_oss_http_client() -> Result<reqwest::Client, AppSta
})
}
// 中文注释:编辑器参考图 / 派生图的 OSS GET 此前每次调用都 `reqwest::Client::new()`
// 既没有任何超时也没有连接复用——一个半开或黑洞的连接可以无限期挂住,同时占着
// HTTP 准入许可和已经读入的最多 32 MiB 图片缓冲。这里收口成进程级共享客户端,
// 给整段 GET(含 body 流式读取)一个绝对上界,作为所有调用方的兜底。
// connect 取 10s:同区域 OSS 建连是百毫秒级,30s 只会让不可达端点多占 20s 槽位。
// total 取 60s:需要容纳 EDITOR_REFERENCE_IMAGE_MAX_SIZE_BYTES 上限对象在慢链路上读完。
fn build_editor_oss_read_http_client() -> Result<reqwest::Client, AppStateInitError> {
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");