From 3f6a8e7a0c54491db627b3c848efef77ae193813 Mon Sep 17 00:00:00 2001 From: Linghong Date: Mon, 3 Aug 2026 07:28:07 +0000 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E6=B5=8B=E5=AE=8C=E7=BE=8E=E5=83=8F?= =?UTF-8?q?=E7=B4=A0=E5=B9=B6=E5=8F=91=E9=97=B8=E7=AB=9E=E4=BA=89=E8=B7=AF?= =?UTF-8?q?=E5=BE=84=E4=B8=8E=20snapper=20=E9=94=99=E8=AF=AF=E5=88=86?= =?UTF-8?q?=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 并发闸的三条竞争路径此前零测试,retry-after 在整个 crate 里只出现在生产代码 一处;snapper 四档状态码只有 GridNotDetected 被间接覆盖。 不去打满全局信号量或队列计数——两者都是进程级 static,填满会让并行跑的其他 用例连带失败,正是刚修掉的那类竞态。改为把三条路径的错误抽成构造函数直接断言 状态码、文案与 retry-after 头;路径与构造函数的对应由既有顺序守卫钉住,CAS 边界本来就有本地计数器用例覆盖。 这样覆盖的是错误形状与分档,不是端到端竞争行为;后者需要把限流器与队列计数 改成依赖注入,改动面超出补测本身,未做。 删掉 retry-after 或把 Decode 改判 500,两条新用例分别精确变红。 Co-Authored-By: Claude Opus 5 --- .../shared-memory/decision-log.md | 9 ++ .../crates/api-server/src/editor_project.rs | 128 ++++++++++++++++-- 2 files changed, 122 insertions(+), 15 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index eb3d11b3b..ff8a4f7b1 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -6052,3 +6052,12 @@ - 已知残留:A 真死了而用户在 180 秒内重新加载时,占位会继续转到窗口过后的下一次加载才清掉。可以加客户端定时器在剩余时间后自行收口,但要多一套定时器生命周期管理,先不加,观察实际是否困扰。 - 验证:新增四条用例覆盖窗口内保留、边界包含式、超窗剥离、缺时间戳兜底;去掉时间窗判断后其中两条变红。`vitest src/components/image-editor` 899 通过 / 72 文件,typecheck、eslint 通过。 - 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +## 2026-08-03 完美像素并发闸竞争路径与 snapper 错误分档补测 + +- 背景:审查留下的两处覆盖缺口。并发闸的三条竞争路径(队列满 503 + `retry-after`、等待槽位超时 504、信号量关闭 503)此前零测试——`retry-after` 在整个 crate 里只出现在生产代码一处;`map_editor_pixel_art_snapper_error` 的四档状态码只有 `GridNotDetected → 422` 被间接覆盖。 +- 测试方式的取舍:不去把全局信号量或队列计数打满。两者都是进程级 `static`,在测试里填满会让并行跑的其他用例连带失败——正是本文件同日刚修掉的 E1 那类竞态,不能一边修一边再造一个。改为把三条路径的错误各自抽成构造函数,直接断言状态码、文案和 `retry-after` 头;「哪条路径用哪个构造函数」由 `snap_editor_image_to_pixel_art` 的既有顺序守卫钉住,CAS 边界本来就有本地计数器的用例覆盖。 +- 覆盖边界要说清:这样覆盖的是错误形状与分档,不是端到端的竞争行为。真要覆盖后者需要把限流器与队列计数改成依赖注入,改动面超出补测本身,未做。 +- 分档的双向后果写进了断言注释:把用户上传的坏图(`Decode`)报成 500 会让客户端当服务端故障去重试;把服务端自身失败(`Encode` / `Processing`)报成 400 又会让用户以为是自己的输入有问题。另断言底层文案原样带上,否则「识别不到网格」与「解码失败」在用户侧无法区分。 +- 验证:删掉 `retry-after` 或把 `Decode` 改判 500,两条新用例分别精确变红。api-server 679 通过 / 3 失败(`wallet_refund_outbox` 本机环境失败,与基线一致),`cargo fmt --check` 通过。 +- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 7b3426da1..2e3655989 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3341,6 +3341,32 @@ impl Drop for EditorPixelArtSnapQueueGuard { // 中文注释:端点级并发闸,必须在第一次 IO 之前取得,许可持有到 handler 结束。等待时间与 // 后续处理共用同一份 processing_deadline,所以排队不会让请求重新获得完整预算。 +// 中文注释:三条竞争路径的错误各自抽成构造函数。它们要靠全局信号量被打满或队列计数到顶 +// 才走得到,而那两个都是进程级 static——在测试里把它们填满会让并行跑的其他用例连带失败, +// 正是本文件刚修掉的那类竞态。抽出来之后状态码、文案和 retry-after 可以直接断言, +// 「哪条路径用哪个构造函数」则由 snap_editor_image_to_pixel_art 的顺序守卫钉住。 +fn editor_pixel_art_snap_queue_full_error() -> AppError { + editor_pixel_art_snap_failure( + StatusCode::SERVICE_UNAVAILABLE, + "完美像素排队已满,请稍后重试。", + ) + .with_header("retry-after", HeaderValue::from_static("1")) +} + +fn editor_pixel_art_snap_limiter_unavailable_error(error: impl std::fmt::Display) -> AppError { + editor_pixel_art_snap_failure( + StatusCode::SERVICE_UNAVAILABLE, + format!("完美像素并发门限不可用:{error}"), + ) +} + +fn editor_pixel_art_snap_wait_timeout_error() -> AppError { + editor_pixel_art_snap_failure( + StatusCode::GATEWAY_TIMEOUT, + "完美像素等待处理槽位时预算已耗尽。", + ) +} + async fn acquire_editor_pixel_art_snap_permit( processing_deadline: Instant, ) -> Result { @@ -3353,13 +3379,8 @@ async fn acquire_editor_pixel_art_snap_permit( "完美像素处理预算已耗尽。", )); } - let queue_guard = EditorPixelArtSnapQueueGuard::try_enter().ok_or_else(|| { - editor_pixel_art_snap_failure( - StatusCode::SERVICE_UNAVAILABLE, - "完美像素排队已满,请稍后重试。", - ) - .with_header("retry-after", HeaderValue::from_static("1")) - })?; + let queue_guard = EditorPixelArtSnapQueueGuard::try_enter() + .ok_or_else(editor_pixel_art_snap_queue_full_error)?; let acquired = tokio::time::timeout_at( tokio::time::Instant::from_std(processing_deadline), Arc::clone(&*EDITOR_PIXEL_ART_SNAP_LIMITER).acquire_owned(), @@ -3369,14 +3390,8 @@ async fn acquire_editor_pixel_art_snap_permit( drop(queue_guard); match acquired { Ok(Ok(permit)) => Ok(permit), - Ok(Err(error)) => Err(editor_pixel_art_snap_failure( - StatusCode::SERVICE_UNAVAILABLE, - format!("完美像素并发门限不可用:{error}"), - )), - Err(_) => Err(editor_pixel_art_snap_failure( - StatusCode::GATEWAY_TIMEOUT, - "完美像素等待处理槽位时预算已耗尽。", - )), + Ok(Err(error)) => Err(editor_pixel_art_snap_limiter_unavailable_error(error)), + Err(_) => Err(editor_pixel_art_snap_wait_timeout_error()), } } @@ -12198,6 +12213,89 @@ mod tests { ); } + #[test] + fn pixel_art_snap_contended_paths_carry_their_documented_responses() { + use axum::response::IntoResponse; + + // 中文注释:队列满必须是 503 且带 retry-after——客户端与网关据此决定退避,删掉这个头 + // 会让重试立刻打回来,把保险丝变成放大器。此前 `retry-after` 在整个 crate 里只出现在 + // 生产代码一处,零断言。 + let queue_full = editor_pixel_art_snap_queue_full_error(); + assert_eq!(queue_full.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert!(queue_full.body_text().contains("完美像素排队已满")); + let queue_full_response = queue_full.into_response(); + assert_eq!( + queue_full_response.headers().get("retry-after"), + Some(&HeaderValue::from_static("1")), + "queue-full responses must tell the caller when to retry" + ); + + // 中文注释:等待槽位超时是 504 而不是 503——预算耗尽属于超时语义,错成 503 会让客户端 + // 把「这次来不及了」当成「服务暂时不可用」而立刻重试。 + let wait_timeout = editor_pixel_art_snap_wait_timeout_error(); + assert_eq!(wait_timeout.status_code(), StatusCode::GATEWAY_TIMEOUT); + assert!(wait_timeout.body_text().contains("预算已耗尽")); + + // 中文注释:信号量被关闭是服务端自身不可用,503;同时必须把底层错误带进文案,否则 + // 这条实践中极难复现的路径在排障时只剩一句无信息的通用文案。 + let unavailable = editor_pixel_art_snap_limiter_unavailable_error("semaphore closed"); + assert_eq!(unavailable.status_code(), StatusCode::SERVICE_UNAVAILABLE); + assert!(unavailable.body_text().contains("完美像素并发门限不可用")); + assert!(unavailable.body_text().contains("semaphore closed")); + } + + #[test] + fn pixel_art_snapper_errors_map_to_their_documented_status_codes() { + // 中文注释:四个状态码分档此前只有 GridNotDetected 被间接覆盖。分档错位的后果是双向的: + // 把用户上传的坏图(Decode)报成 500 会让客户端当作服务端故障去重试,把服务端自身失败 + // (Encode / Processing)报成 400 又会让用户以为是自己的输入有问题。 + for (error, expected_status) in [ + ( + platform_image::PixelArtSnapError::InvalidInput("空输入".to_string()), + StatusCode::BAD_REQUEST, + ), + ( + platform_image::PixelArtSnapError::Decode { + input: "source", + message: "corrupt png".to_string(), + }, + StatusCode::BAD_REQUEST, + ), + ( + platform_image::PixelArtSnapError::GridNotDetected, + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + platform_image::PixelArtSnapError::DeadlineExceeded { + stage: "输入解码" + }, + StatusCode::GATEWAY_TIMEOUT, + ), + ( + platform_image::PixelArtSnapError::Encode("encode failed".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ( + platform_image::PixelArtSnapError::Processing("resize failed".to_string()), + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let rendered = error.to_string(); + let mapped = map_editor_pixel_art_snapper_error(error); + assert_eq!( + mapped.status_code(), + expected_status, + "unexpected status for {rendered}" + ); + // 中文注释:底层文案必须原样带上。丢掉它,客户端只会看到一句通用失败, + // 「识别不到网格」和「解码失败」在用户侧变得无法区分。 + assert!( + mapped.body_text().contains(rendered.as_str()), + "mapped error should carry the snapper message: {rendered}" + ); + } + } + #[test] fn pixel_art_server_worst_case_fits_inside_the_client_timeout() { // 中文注释:这条不变式是跨端的,钉住它才能防止任一侧被单独调大。客户端