收紧完美像素来源读取的时间边界
OSS 读取收口为进程级共享客户端并设置连接与整体超时 完美像素处理预算改从请求入口起算以覆盖下载阶段 补齐共享客户端、预算起点与带界下载的回归断言 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()",
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user