BgFilter 连接重连冷启动宽限并收口重试契约一致性
一并落地评审的三个 P2: 1. 冷启动宽限:重连配额按「本进程是否已连通过 worker」分档(AppState 级 标记,收到任意 HTTP 响应即算)。首连前 flat 升级为完整序列 22.5s、 complex 追加 8×5s 至约 62.5s,覆盖主机开机竞态与慢开机;首连后回落 flat ≤1.5s / complex 22.5s 保持运行中途快速收口。档位单次调用内锁定。 2. 安全不变量测试:TCP 已 accept、未回任何 HTTP 字节即断开 → 不得发起 第二次连接,以 mock listener accept 计数证明;另补冷启动档 flat 跨过 超出常规配额监听空窗的集成测试。 3. 文档契约全局一致:设计摘要表 / 实施步骤 / 运维文档 / 外部生成方案 / 编辑器专题 / 前端画布方案中的旧禁令统一改为「不重试已被 worker 接收 的内部 RPC(连接从未建立时按 §5.1 有界重连)」;数据契约澄清计量单位 (重连只增加连接尝试次数,不产生第二次被接收的 RPC)并写明分档配额; decision-log 追加 2026-07-23 决策条目(含 Windows 平台差异)并在两条 历史更正处补指针。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -134,7 +134,7 @@
|
||||
|
||||
## 2026-07-15 BgFilter 输入改用私有 OSS 短期签名 URL
|
||||
|
||||
> 后续更正(2026-07-21):复用 object key、通过 `image_url` 提交且不传 `file` 的协议语义保留,但 600 秒 OSS GET URL 的签发和 BgFilter provider multipart 调用已迁入唯一 `bgfilter-worker`。父流程只向内部 worker 发送一次 object key、参数和剩余预算,不签发 BgFilter URL,也不重试整次内部 RPC。下文保留作历史记录。
|
||||
> 后续更正(2026-07-21):复用 object key、通过 `image_url` 提交且不传 `file` 的协议语义保留,但 600 秒 OSS GET URL 的签发和 BgFilter provider multipart 调用已迁入唯一 `bgfilter-worker`。父流程只向内部 worker 发送一次 object key、参数和剩余预算,不签发 BgFilter URL,也不重试已被 worker 接收的内部 RPC(2026-07-23 起:连接从未建立的失败按调度方案 §5.1 有界重连,见当日决策条目)。下文保留作历史记录。
|
||||
|
||||
- 背景:角色形象、图标图集、UI 素材图集、角色动作抽取帧和手动去背景在调用 BgFilter 前都已有私有 OSS object key;继续由 api-server 下载或保留图片并作为 multipart `file` 再上传,会重复传输图片字节并占用 API 进程网络与内存。
|
||||
- 决策:上述抠图链路统一复用 object key,签发 600 秒 OSS GET URL,并通过 BgFilter multipart 的 `image_url` 字段提交;请求中不再携带 `file`。签名 URL 只交给 BgFilter,不写日志或持久化。2026-07-17 起,生成原图和动作帧上传后不再保留图片字节;进入“阿里云通用抠图 → 本地键色”兜底链时按阶段从私有 OSS 重新下载。
|
||||
@@ -161,7 +161,7 @@
|
||||
|
||||
## 2026-07-15 手动复杂去背景复用 BgFilter 单次重试
|
||||
|
||||
> 后续更正(2026-07-21):首次失败后再尝试一次、即同一次 complex 逻辑调用最多两次顺序 provider attempt 的语义保留,但重试所有权已迁入唯一 `bgfilter-worker`。父 `external-generation-worker` 只发送一次内部 HTTP RPC,不重试整次 RPC;两次 provider attempt 都失败时,子 worker 把最终类型化错误返回父流程,complex 仍不接入 flat 的阿里云 / 本地 fallback。下文所称“worker 重试”按此边界理解。
|
||||
> 后续更正(2026-07-21):首次失败后再尝试一次、即同一次 complex 逻辑调用最多两次顺序 provider attempt 的语义保留,但重试所有权已迁入唯一 `bgfilter-worker`。父 `external-generation-worker` 至多让 worker 接收一次内部 HTTP RPC,不重试已被接收的 RPC(2026-07-23 起:连接从未建立的失败按调度方案 §5.1 有界重连,见当日决策条目);两次 provider attempt 都失败时,子 worker 把最终类型化错误返回父流程,complex 仍不接入 flat 的阿里云 / 本地 fallback。下文所称“worker 重试”按此边界理解。
|
||||
|
||||
- 背景:图片画布手动去背景已经改用 BgFilter `background_mode=complex`,但 worker 仍只发送一次上游请求,短暂网络抖动会直接让任务失败。
|
||||
- 决策:手动去背景的 complex 请求复用现有 `EDITOR_BGFILTER_RETRY_COUNT=1`,首次请求失败后立即重试一次,两次都失败仍返回最终错误;本次不把手动 complex 接入标准纯色背景链路的阿里云 / 本地兜底,也不改变 flat 路径的熔断状态。
|
||||
@@ -4417,3 +4417,12 @@
|
||||
- 运维与回滚:用 `scripts/gitea-ci-job-image.sh build|verify|export|load-runner` 管理镜像,按 `build/verify -> export 仓库外镜像归档和 SHA-256 sidecar -> load-runner -> 确认无活跃 job -> 备份 config -> 增加或替换 label -> docker restart --timeout 660` 切换。config 与镜像归档只放仓库外受控位置;共享文档只记录通用备份规则,不记录宿主绝对路径、注册信息或 token。重启后先验证真实 CI 再清理旧镜像;回滚先把 workflow `runs-on` 改回 `ubuntu-latest`,再恢复 config 备份并重启 runner。
|
||||
- 影响范围:`.gitea/workflows/project-ci.yml`、`deploy/container/gitea-ci-job.Dockerfile`、`scripts/gitea-ci-job-image.sh`、`scripts/check-gitea-ci-job-image.sh`、`scripts/check-gitea-ci-job-runtime.sh`、runner label/config 和 Gitea CI 运维文档。
|
||||
- 验证方式:构建脚本校验宿主与 runner 内层 Image ID 一致;环境脚本校验 Node、Rust、`rustfmt`、Chrome、bwrap、原生命令与 pkg-config 依赖;runtime 脚本执行完整 bwrap 和 Chrome headless canary;真实 PR 的四个 job 全部通过,同时复核 `Privileged=false`、`Binds=[]`、`MaskedPaths=[]`、`ReadonlyPaths=[]` 和独立网络。
|
||||
|
||||
## 2026-07-23 BgFilter 父侧连接失败有界重连与冷启动宽限
|
||||
|
||||
- 背景:主机重启或 worker 崩溃拉起期间,父侧对 loopback BgFilter worker 的 TCP 连接失败此前直接映射 `internal_error`:complex(队列 `max_attempts=1`)终态失败不可自愈,flat 被迫降级。systemd 层修复被否决——`After=` 在 `Type=simple` 下只保证 fork 顺序;`Type=notify` + 依赖会把 API / external worker 冷启动变成 BgFilter 的人质。
|
||||
- 决策:仅对「TCP 连接从未建立」的失败(连接拒绝 / 不可达 / connect 阶段超时)做有界退避重连——这类请求从未进入 worker admission,无副作用、天然幂等;连接已建立后的任何失败(结果未知)与收到任何 HTTP 响应(含 5xx)维持原「不重试」禁令。每轮重连前按现有公式重算 `maxQueueWaitMs`,不突破「预算不足不发送」不变量。计量单位澄清:一次逻辑调用至多被 worker 接收一次 RPC,重连增加的只是连接尝试次数。
|
||||
- 冷启动宽限:重连配额按「本进程是否已连通过 worker」(收到任意 HTTP 响应即算,`AppState` 级标记)分档——冷启动档 flat 22.5s / complex 约 62.5s(覆盖开机竞态与慢开机),常规档 flat ≤1.5s(不侵蚀 39s fallback 预留)/ complex 22.5s(覆盖 `RestartSec=5s`+ 启动窗);档位单次调用内锁定。新增 `bgfilter_internal_connect_retry_total{mode}` 指标。
|
||||
- 平台差异(Windows 开发环境):连接已关闭的 loopback 端口不回 RST 而是挂到 connect timeout,错误呈现为 `deadline_exceeded` 且 `is_connect` 为真;重连判定只看 connect 分类,不看错误码。生产 Linux 即时拒绝,呈现 `internal_error`。
|
||||
- 安全不变量测试:除配额 / 跨窗 / deadline 地板路径外,专项覆盖「TCP 已 accept、未回任何 HTTP 字节即断开 → 不得发起第二次连接」,以 mock listener 的 accept 计数证明父侧未重连。
|
||||
- 影响范围:`server-rs/crates/api-server/src/bgfilter_worker.rs`、`state.rs`、`editor_project.rs`、调度方案 §5.1/§7/§9.3/§11、数据契约 247-248 行、运维文档及各编辑器专题文档的旧禁令措辞统一改为「不重试已被 worker 接收的内部 RPC」。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -17,7 +17,7 @@
|
||||
| 成功输出(子 → 父) | 内部 HTTP body 直接传回 BgFilter 结果图片的原始字节;不使用 Base64、不返回结果 object key、不先写 raw OSS |
|
||||
| BgFilter worker | 首版只运行一个内部 HTTP worker 实例 |
|
||||
| 并发 | 进程内 `Semaphore(N)`,并增加有界 admission 上限 `Q` |
|
||||
| 重试 | 子 worker 对一次逻辑调用最多做两次顺序 provider attempt;父侧不重试整次内部 RPC |
|
||||
| 重试 | 子 worker 对一次逻辑调用最多做两次顺序 provider attempt;父侧不重试已被 worker 接收的内部 RPC,仅连接从未建立时按预算有界重连(§5.1 / §7.1) |
|
||||
| 超时 | 双预算:父侧派生排队预算 `maxQueueWaitMs` 与调用预算 `callBudgetMs`;attempt 上限由 `N × est × 2` 公式运行时派生(est 默认 `5s`),排队不侵蚀调用时间 |
|
||||
| flat / complex 熔断 | 迁到唯一子 worker;两种模式共享阈值和 `120s` cooldown,但分别维护独立进程内状态 |
|
||||
| 业务语义 | 父流程继续负责 Alpha / 尺寸恢复、flat fallback、最终 OSS、画布写回、计费和父终态 |
|
||||
@@ -227,7 +227,12 @@ parent client timeout = maxQueueWaitMs + callBudgetMs + 2s 传输窗
|
||||
|
||||
`maxQueueWaitMs <= 0` 时父侧不得发送请求:flat 直接进入既有“阿里云 → 本地”fallback,complex 直接失败;不允许把注定超时的请求塞进队列。flat 扣除的 `39s` 父侧预留由 `37s` fallback 窗口和 `2s` 内部响应传输窗组成;complex 没有 fallback,只保留 `2s` 传输窗。
|
||||
|
||||
父侧对「TCP 连接从未建立」的失败(worker 重启、主机开机排序窗口内的连接拒绝 / 不可达 / connect 阶段超时)做有界自动重试:这类请求从未进入 worker admission,无副作用、天然幂等。每轮重试前按上式重算 `maxQueueWaitMs`,重试消耗的是父预算的自然余量,不突破「预算不足不发送」的不变量;退避序列本身有界——complex 用完整序列(总额约 `22.5s`,按上界覆盖 systemd `RestartSec=5s` + 进程启动窗口,更长的停机应快速失败而非挂住父 job),flat 只取前 2 项(额外延迟 `≤1.5s`,不侵蚀 `39s` fallback 预留)——父无绝对 deadline 时也不会无限等待。收到任何 HTTP 响应(含 5xx)或其它错误类别一律不重试,边界见 §7.1。
|
||||
父侧对「TCP 连接从未建立」的失败(worker 重启、主机开机排序窗口内的连接拒绝 / 不可达 / connect 阶段超时)做有界自动重试:这类请求从未进入 worker admission,无副作用、天然幂等。每轮重试前按上式重算 `maxQueueWaitMs`,重试消耗的是父预算的自然余量,不突破「预算不足不发送」的不变量;退避序列本身有界,父无绝对 deadline 时也不会无限等待。配额按「本进程是否已连通过 worker」(收到任意 HTTP 响应即算)分两档:
|
||||
|
||||
- **冷启动档**(首连前,覆盖主机开机竞态与 worker 首次拉起):flat 用完整基础序列(总额约 `22.5s`),complex 在基础序列后追加 `8 × 5s` 平台退避(总额约 `62.5s`,覆盖慢开机)。
|
||||
- **常规档**(首连后,运行中途故障要快速收口):flat 只取前 2 项(额外延迟 `≤1.5s`,不侵蚀 `39s` fallback 预留),complex 用完整基础序列(约 `22.5s`,覆盖 systemd `RestartSec=5s` + 进程启动窗口,更长的停机应快速失败而非挂住父 job)。
|
||||
|
||||
档位在单次调用开始时锁定,中途不切换。收到任何 HTTP 响应(含 5xx)或其它错误类别一律不重试,边界见 §7.1。
|
||||
|
||||
queue job 的总预算从父 job 开始执行时起算,不从开始申请 BgFilter 时重新计时。现有父 worker 还会把 provider deadline 设在 job deadline 前 `60s`,为最终写回和终态保留时间。同步 RPC 实现必须显式读取父侧剩余 provider budget 派生 `maxQueueWaitMs`,不能忽略 `RequestContext` deadline。
|
||||
|
||||
@@ -427,7 +432,7 @@ flat / complex 的每次 provider 失败审计都必须留在子 worker,保留
|
||||
1. 在现有 Rust 后端增加 `bgfilter-worker` 进程角色和独立 loopback Axum listener;它不启动用户 HTTP router,也不 claim `external_generation_job`。
|
||||
2. 增加内部 request / binary response / typed error 契约、Token 校验、JSON body 上限、object key allowlist 和健康检查;listener 在 body 解析前接入连接 / request concurrency limit、固定 backlog 和 load shedding。
|
||||
3. 增加 admission `Q`、`Semaphore(N)`、两次顺序 attempt、预算检查、结果限长 / 解码校验、response-body permit guard 和 flat / complex 独立进程级熔断。
|
||||
4. 增加父侧共享内部 HTTP client。该 client 不自动重试;对 `2xx` 读取并返回受限图片字节,对非 `2xx` 只解析有界类型化 JSON 错误;把父剩余预算显式转换为 `maxQueueWaitMs` 与公式 `callBudgetMs`,client timeout 固定取两者之和加 `2s`。父绝对预算只在派生 `maxQueueWaitMs` 时扣除 callBudget 与父侧预留,不在发送阶段重新裁剪或挪用两笔相对预算。
|
||||
4. 增加父侧共享内部 HTTP client。该 client 不重试已被 worker 接收的请求,仅连接从未建立时按 §5.1 有界重连;对 `2xx` 读取并返回受限图片字节,对非 `2xx` 只解析有界类型化 JSON 错误;把父剩余预算显式转换为 `maxQueueWaitMs` 与公式 `callBudgetMs`,client timeout 固定取两者之和加 `2s`。父绝对预算只在派生 `maxQueueWaitMs` 时扣除 callBudget 与父侧预留,不在发送阶段重新裁剪或挪用两笔相对预算。
|
||||
5. 用内部 client 替换两个集中调用边界:
|
||||
- flat:`remove_editor_generated_screen_background_with_bgfilter`;
|
||||
- complex:`request_editor_background_removal_image_with_bgfilter_worker`。
|
||||
@@ -469,7 +474,7 @@ flat / complex 的每次 provider 失败审计都必须留在子 worker,保留
|
||||
- `maxQueueWaitMs <= 0` 时父侧不发送请求:flat 直接 fallback,complex 直接失败。
|
||||
- `callBudgetMs` 与 worker 本进程公式值不一致时不拒绝:worker 以自身公式值执行,记 warn 并递增漂移指标;发布调优 N / est 的新旧进程共存窗口内,在途 flat 任务仍能正常执行或走既有 fallback,不得因瞬态漂移触发 `invalid_request`(该码禁止 fallback)。attempt、callBudget、client timeout 全部由 `N / est` 运行时派生,代码不存在硬编码结果值。
|
||||
- parent client timeout 精确取 `maxQueueWaitMs + callBudgetMs + 2s`,helper 保持 infallible;父绝对预算通过 `maxQueueWaitMs` 的派生公式预先约束,结果校验等待也必须 deadline-aware,不能只在校验完成后事后判超时。
|
||||
- 第一次失败后预算不足时不开始第二次;父侧从不重试已建立连接的内部 RPC。连接从未建立的失败按 §5.1 有界退避重试:仅 connect 类失败重入、收到任何 HTTP 响应立即停止、每轮重算 `maxQueueWaitMs`、complex / flat 各自的退避配额封顶(Rust 集成测试覆盖「重试跨过监听空窗后停在首个 HTTP 响应」「配额耗尽返回 connect 失败」「deadline 放不下下一轮时不空睡」三条路径)。
|
||||
- 第一次失败后预算不足时不开始第二次;父侧从不重试已建立连接的内部 RPC。连接从未建立的失败按 §5.1 有界退避重试:仅 connect 类失败重入、收到任何 HTTP 响应立即停止、每轮重算 `maxQueueWaitMs`、配额按冷启动 / 常规两档封顶且单次调用内锁定(Rust 集成测试覆盖「重试跨过监听空窗后停在首个 HTTP 响应」「常规档配额耗尽返回 connect 失败」「deadline 放不下下一轮时不空睡」「冷启动档 flat 跨过超出常规配额的监听空窗」,以及安全不变量「TCP 已 accept、未回任何 HTTP 字节即断开 → 不得发起第二次连接」——以 accept 计数证明)。
|
||||
- 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败或自身熔断都直接失败,不接 flat fallback。
|
||||
- flat / complex 分别按自身真实失败 attempt 计数且状态互不影响;由剩余业务预算截短的 timeout 不计入。两种模式都在 permit 前二次检查;已获准调用可完成第二次,后续同模式排队请求快速 `circuit_open`。
|
||||
- `cancelled`(仅验证父侧映射,保留码首版不产生)、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。
|
||||
|
||||
@@ -194,7 +194,7 @@ controller 配置:
|
||||
|
||||
- `editor_image_generation`:普通图片、生成规范、角色形象、UI 设计图、宣发素材和图片快速编辑。
|
||||
- `editor_image_edit`:图片编辑 / 修改结果。
|
||||
- `editor_background_removal`:手动去除任意图片背景,父 `external-generation-worker` 只发送一次内部 HTTP RPC 并把执行阶段标记为 `processing`;唯一 `bgfilter-worker` 使用 BgFilter complex 模式,对同一次逻辑调用最多执行两次顺序 provider attempt,两次都失败时把类型化错误返回父流程。
|
||||
- `editor_background_removal`:手动去除任意图片背景,父 `external-generation-worker` 至多让 worker 接收一次内部 HTTP RPC(连接从未建立时按调度方案 §5.1 有界重连)并把执行阶段标记为 `processing`;唯一 `bgfilter-worker` 使用 BgFilter complex 模式,对同一次逻辑调用最多执行两次顺序 provider attempt,两次都失败时把类型化错误返回父流程。
|
||||
- `editor_icon_spritesheet_generation`:图标素材 spritesheet 生成和拆分。
|
||||
- `editor_ui_design_asset_extraction`:UI 设计图红框素材提取。
|
||||
- `editor_character_animation_generation`:角色动作视频和帧素材生成。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -76,7 +76,7 @@ lease 过期后不代表任务一定再次执行:claim transaction 只有在 `
|
||||
|
||||
图片画布角色图、图标素材、UI 素材提取和角色动作逐帧去背景使用 `background_mode=flat`;手动 `POST /api/editor/images/background-removals` 使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`。父流程不再直连 BgFilter,而是把已持久化的私有 OSS object key、模式参数、排队预算 `maxQueueWaitMs` 和调用预算 `callBudgetMs` 交给唯一的 loopback `bgfilter-worker`。子 worker 负责签发短期源 URL、全局 admission `Q`、provider 并发 `N`、最多两次顺序 attempt、结果校验和按 flat / complex 隔离的进程级熔断;成功时直接用内部 HTTP 二进制 body 把原始结果图片字节返回父流程,不写 raw OSS。当前冻结 `N=16`、单图估时 `est=5000ms`,`Q` 默认 `2048` 且仅作为连接风暴保险丝;角色动画仍可同时提交最多 `48` 个单帧逻辑调用,但健康 worker 中实际在飞的 BgFilter provider 请求不超过 `N`。
|
||||
|
||||
BgFilter 不再配置独立的固定 attempt timeout。父子共同按 `attempt = N × est × 2` 派生单次真实 provider attempt 上限,并按 `callBudgetMs = 2 × attempt + 1s` 派生调用预算;当前 `N=16 / est=5000ms` 时分别为 `160s / 321s`。父侧仍管理父 job 总预算,按剩余绝对预算派生 `maxQueueWaitMs`;子 worker 从 admission 开始只用该字段等待 provider permit,取得 permit 后才启动 `callBudgetMs`,排队不侵蚀两次完整 attempt 窗口。flat 还由父侧预留阿里云 request timeout 和本地处理余量。flat 两次失败、熔断、overload 或内部 RPC 故障且父业务预算仍有效时,父流程才继续“阿里云通用抠图 → 本地键色”;阿里云 fallback 不属于 BgFilter worker。complex 的真实 provider 失败只累计自身熔断,任意失败或熔断仍直接使父流程失败,不接 flat fallback,也不影响 flat 状态。父侧不会重试整次内部 HTTP,避免子侧两次乘成四次 provider attempt。
|
||||
BgFilter 不再配置独立的固定 attempt timeout。父子共同按 `attempt = N × est × 2` 派生单次真实 provider attempt 上限,并按 `callBudgetMs = 2 × attempt + 1s` 派生调用预算;当前 `N=16 / est=5000ms` 时分别为 `160s / 321s`。父侧仍管理父 job 总预算,按剩余绝对预算派生 `maxQueueWaitMs`;子 worker 从 admission 开始只用该字段等待 provider permit,取得 permit 后才启动 `callBudgetMs`,排队不侵蚀两次完整 attempt 窗口。flat 还由父侧预留阿里云 request timeout 和本地处理余量。flat 两次失败、熔断、overload 或内部 RPC 故障且父业务预算仍有效时,父流程才继续“阿里云通用抠图 → 本地键色”;阿里云 fallback 不属于 BgFilter worker。complex 的真实 provider 失败只累计自身熔断,任意失败或熔断仍直接使父流程失败,不接 flat fallback,也不影响 flat 状态。父侧不会重试已被 worker 接收的内部 HTTP(连接从未建立的失败按调度方案 §5.1 有界重连),避免子侧两次乘成四次 provider attempt。
|
||||
|
||||
阿里云通用抠图默认 `GENARRATIVE_ALIYUN_MATTING_ENABLED=true`,但必须在 `api-server.env` 填入 `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID` / `GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET`(或标准 SDK 命名 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`)才会真正启用;AccessKey 缺失时启动日志会打印「阿里云抠图 AccessKey 未配置,跳过抠图客户端初始化」,flat 失败后直接进入本地键色。两种模式的 provider 配置统一使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`,父子共同使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY` 与 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS` 派生预算;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只保留为 token 兼容别名。修改 provider、内部 worker 或 fallback 配置后,需要按角色重启对应进程。
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
|
||||
```
|
||||
|
||||
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲,不克隆保留),写入项目资源和账号素材库;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt,默认 `segModel=birefnet`。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。透明背景处理正常成功时,父流程把透明 spritesheet 写入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及拆分切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。
|
||||
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲,不克隆保留),写入项目资源和账号素材库;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt,默认 `segModel=birefnet`。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。透明背景处理正常成功时,父流程把透明 spritesheet 写入 OSS、项目资源和账号素材库,再复用图标素材的连通域拆分能力;调用方未指定素材文件夹时落默认“项目”文件夹。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及拆分切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。
|
||||
- UI 素材自动拆分只在透明图集成功后执行,与图标图集一致,属于 best-effort 附加动作。未知素材数量时按从上到下、从左到右自动命名为 `素材 1`、`素材 2`;识别或切片持久化失败仍返回整张透明图集和 `sliceWarning`,前端显示非阻断 warning toast,用户可手动重试。`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。
|
||||
- 正常透明化成功时,前端先把透明 spritesheet 作为 `assetKind: "icon-spritesheet"` 图集图层放在 UI 设计图右侧,再把拆分成功的独立素材作为 `assetKind: "icon"` 图标图层继续放到画布;透明背景处理最终失败时只消费后端快照中的 provider 原图。透明图集图层提供 `拆分图集` 工具栏按钮,可使用相同连通域规则重新拆分。
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
|
||||
## 去背与保存
|
||||
|
||||
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。
|
||||
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图写入私有 OSS,并在上传完成后释放原图缓冲;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试已被 worker 接收的内部 RPC(连接从未建立时按调度方案 §5.1 有界重连)。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用;排队只消耗 `maxQueueWaitMs`,取得 provider permit 后才启动 `callBudgetMs`。每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在调用预算内最多执行两次顺序 attempt。前端用户路径固定提交 `screenColor=auto` 与默认 `segModel=birefnet`,后端仍识别内部保留的 `anime-seg`,但这些内部参数不对用户可见。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。
|
||||
- 透明背景处理正常成功时,父流程把带背景原图和去背后的透明 spritesheet 写入 OSS、项目资源和账号素材库,再按 alpha 连通域和素材描述顺序执行附加拆分;若 BgFilter 返回较小图集,只把 alpha 蒙版重采样到 provider 原图尺寸并应用回原始高分辨率 RGB,不放大低分辨率后处理成品。画布完成快照同时写入透明主图与右侧 provider 原图(二者均已登记为 project resource / 账号素材),`generatedLayerId` 仍锚定透明主图;成功拆出的切片从 provider 原图右侧继续排列。调用方未指定素材文件夹时统一落默认“项目”文件夹。每个成功切片单独写入 OSS、项目资源和账号素材库,`sourceResourceId` 指向透明图集资源。BgFilter 与父侧 fallback 最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建透明图集,也不继续拆分,`iconImageSrcs=[]`。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果及切片的 OSS / 资源 / 画布持久化仍全部由父流程负责。
|
||||
- 自动拆分只在透明图集成功后执行,属于 best-effort 附加动作,不参与图集生成的成功判定。连通域识别或切片持久化失败时,接口仍返回并回填整张透明图集,`iconImageSrcs=[]`,并通过 `sliceWarning.code/reason` 暴露非阻断原因;`sliceWarning` 与透明背景最终失败使用的通用 `warning` 互斥,前者只表示透明图集成功但自动拆分失败,`sliceWarning.reason` 原始契约保持不变。前端在 inline、worker 队列完成和刷新恢复三条路径统一显示对应 warning toast,用户可在图集工具栏手动重试。
|
||||
- 响应通过 `iconImageSrcs` 返回成功切片素材;自动生成使用用户输入的素材描述命名,UI 设计提取和手动拆分按从上到下、从左到右自动命名为 `素材 N`。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -58,6 +58,12 @@ const BGFILTER_CONNECT_RETRY_BACKOFF_MS: [u64; 7] = [500, 1_000, 2_000, 4_000, 5
|
||||
/// flat 有完整 fallback 链,重试只为跨过最短窗口,不得侵蚀 fallback 预留:
|
||||
/// 只取退避序列前 2 项(额外延迟 ≤1.5s)。
|
||||
const BGFILTER_FLAT_CONNECT_RETRY_LIMIT: usize = 2;
|
||||
/// 冷启动宽限:本进程尚未连通过 worker 时(主机开机 / 进程首连窗口),complex 在
|
||||
/// 完整序列后追加 8 × 5s 平台退避,把首连包络扩到约 62.5s 覆盖慢开机;flat 冷启动
|
||||
/// 直接用完整基础序列(22.5s)。首次收到任意 HTTP 响应后回落到常规配额,运行中途
|
||||
/// 故障保持快速降级 / 失败。
|
||||
const BGFILTER_COLD_START_EXTRA_RETRY_STEPS: usize = 8;
|
||||
const BGFILTER_COLD_START_EXTRA_BACKOFF_MS: u64 = 5_000;
|
||||
|
||||
static BGFILTER_FLAT_CIRCUIT: OnceLock<Mutex<BgfilterCircuitState>> = OnceLock::new();
|
||||
static BGFILTER_COMPLEX_CIRCUIT: OnceLock<Mutex<BgfilterCircuitState>> = OnceLock::new();
|
||||
@@ -2089,18 +2095,33 @@ fn worker_image_response(image: BgfilterImage, request_id: &str) -> Response {
|
||||
}
|
||||
|
||||
/// 决定第 `attempt` 次连接失败后是否还允许重试及退避时长;`None` 表示配额用尽。
|
||||
fn connect_retry_backoff(mode: BgfilterBackgroundMode, attempt: usize) -> Option<Duration> {
|
||||
let limit = match mode {
|
||||
BgfilterBackgroundMode::Flat => BGFILTER_FLAT_CONNECT_RETRY_LIMIT,
|
||||
BgfilterBackgroundMode::Complex => BGFILTER_CONNECT_RETRY_BACKOFF_MS.len(),
|
||||
};
|
||||
if attempt >= limit {
|
||||
return None;
|
||||
/// `cold_start` 为真(本进程尚未连通过 worker)时使用宽限档,覆盖开机竞态窗口。
|
||||
fn connect_retry_backoff(
|
||||
mode: BgfilterBackgroundMode,
|
||||
attempt: usize,
|
||||
cold_start: bool,
|
||||
) -> Option<Duration> {
|
||||
let base = &BGFILTER_CONNECT_RETRY_BACKOFF_MS;
|
||||
match (mode, cold_start) {
|
||||
(BgfilterBackgroundMode::Flat, false) => {
|
||||
if attempt >= BGFILTER_FLAT_CONNECT_RETRY_LIMIT {
|
||||
return None;
|
||||
}
|
||||
base.get(attempt).copied().map(Duration::from_millis)
|
||||
}
|
||||
(BgfilterBackgroundMode::Flat, true) | (BgfilterBackgroundMode::Complex, false) => {
|
||||
base.get(attempt).copied().map(Duration::from_millis)
|
||||
}
|
||||
(BgfilterBackgroundMode::Complex, true) => {
|
||||
if let Some(backoff) = base.get(attempt) {
|
||||
return Some(Duration::from_millis(*backoff));
|
||||
}
|
||||
if attempt < base.len() + BGFILTER_COLD_START_EXTRA_RETRY_STEPS {
|
||||
return Some(Duration::from_millis(BGFILTER_COLD_START_EXTRA_BACKOFF_MS));
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
BGFILTER_CONNECT_RETRY_BACKOFF_MS
|
||||
.get(attempt)
|
||||
.copied()
|
||||
.map(Duration::from_millis)
|
||||
}
|
||||
|
||||
/// 围绕 `request_bgfilter_worker` 的连接失败有界重试:覆盖 worker 重启与主机
|
||||
@@ -2120,6 +2141,9 @@ pub(crate) async fn request_bgfilter_worker_with_connect_retry(
|
||||
audit: &ExternalApiAuditContext,
|
||||
) -> Result<BgfilterImage, BgfilterClientError> {
|
||||
let call_budget_ms = state.config.bgfilter_call_budget_ms();
|
||||
// 冷启动档在单次调用内锁定:中途其它请求连通 worker 不改变本次已选定的配额,
|
||||
// 避免档位切换把已进行中的重试序列截短。
|
||||
let cold_start = !state.bgfilter_worker_reached();
|
||||
let mut connect_failures = 0usize;
|
||||
loop {
|
||||
let max_queue_wait_ms = max_queue_wait_ms(
|
||||
@@ -2145,7 +2169,8 @@ pub(crate) async fn request_bgfilter_worker_with_connect_retry(
|
||||
if !error.is_connect_failure() {
|
||||
return Err(error);
|
||||
}
|
||||
let Some(backoff) = connect_retry_backoff(background_mode, connect_failures) else {
|
||||
let Some(backoff) = connect_retry_backoff(background_mode, connect_failures, cold_start)
|
||||
else {
|
||||
return Err(error);
|
||||
};
|
||||
if let Some(deadline) = audit.external_call_deadline {
|
||||
@@ -2166,6 +2191,7 @@ pub(crate) async fn request_bgfilter_worker_with_connect_retry(
|
||||
tracing::warn!(
|
||||
background_mode = background_mode.as_str(),
|
||||
connect_failures,
|
||||
cold_start,
|
||||
backoff_ms = duration_millis(backoff),
|
||||
error = %error.message,
|
||||
"bgfilter_worker_connect_retry"
|
||||
@@ -2242,6 +2268,8 @@ pub(crate) async fn request_bgfilter_worker(
|
||||
transport: true,
|
||||
connect: error.is_connect(),
|
||||
})?;
|
||||
// 收到任意 HTTP 响应(含错误状态)即证明 listener 在线:结束本进程的冷启动宽限期。
|
||||
state.mark_bgfilter_worker_reached();
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return read_worker_error(response, status).await;
|
||||
@@ -3334,31 +3362,61 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_retry_backoff_is_bounded_per_mode() {
|
||||
// flat 只取前 2 项:额外延迟 ≤1.5s,不侵蚀 39s fallback 预留。
|
||||
fn connect_retry_backoff_is_bounded_per_mode_and_cold_start_tier() {
|
||||
let base_len = BGFILTER_CONNECT_RETRY_BACKOFF_MS.len();
|
||||
// 常规档 flat 只取前 2 项:额外延迟 ≤1.5s,不侵蚀 39s fallback 预留。
|
||||
assert_eq!(
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, 0),
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, 0, false),
|
||||
Some(Duration::from_millis(500))
|
||||
);
|
||||
assert_eq!(
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, 1),
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, 1, false),
|
||||
Some(Duration::from_millis(1_000))
|
||||
);
|
||||
assert_eq!(connect_retry_backoff(BgfilterBackgroundMode::Flat, 2), None);
|
||||
// complex 用完整退避序列(总额 22.5s),覆盖 RestartSec=5s + 进程启动窗口;
|
||||
// 序列有界,父无绝对 deadline 时也不会无限重试。
|
||||
let total: u64 = (0..BGFILTER_CONNECT_RETRY_BACKOFF_MS.len())
|
||||
assert_eq!(
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, 2, false),
|
||||
None
|
||||
);
|
||||
// 常规档 complex 用完整基础序列(总额 22.5s),覆盖 RestartSec=5s + 进程启动窗口。
|
||||
let base_total: u64 = (0..base_len)
|
||||
.map(|attempt| {
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Complex, attempt)
|
||||
.expect("complex 应可用完整退避序列")
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Complex, attempt, false)
|
||||
.expect("complex 应可用完整基础序列")
|
||||
.as_millis() as u64
|
||||
})
|
||||
.sum();
|
||||
assert_eq!(total, 22_500);
|
||||
assert_eq!(base_total, 22_500);
|
||||
assert_eq!(
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Complex, base_len, false),
|
||||
None
|
||||
);
|
||||
// 冷启动档:flat 升级为完整基础序列(22.5s),覆盖开机竞态与 worker 自身拉起。
|
||||
let cold_flat_total: u64 = (0..base_len)
|
||||
.map(|attempt| {
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, attempt, true)
|
||||
.expect("冷启动 flat 应可用完整基础序列")
|
||||
.as_millis() as u64
|
||||
})
|
||||
.sum();
|
||||
assert_eq!(cold_flat_total, 22_500);
|
||||
assert_eq!(
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Flat, base_len, true),
|
||||
None
|
||||
);
|
||||
// 冷启动档 complex 追加 8 × 5s 平台退避,总额 62.5s 覆盖慢开机;仍然有界。
|
||||
let cold_complex_total: u64 = (0..base_len + BGFILTER_COLD_START_EXTRA_RETRY_STEPS)
|
||||
.map(|attempt| {
|
||||
connect_retry_backoff(BgfilterBackgroundMode::Complex, attempt, true)
|
||||
.expect("冷启动 complex 应可用加长序列")
|
||||
.as_millis() as u64
|
||||
})
|
||||
.sum();
|
||||
assert_eq!(cold_complex_total, 62_500);
|
||||
assert_eq!(
|
||||
connect_retry_backoff(
|
||||
BgfilterBackgroundMode::Complex,
|
||||
BGFILTER_CONNECT_RETRY_BACKOFF_MS.len()
|
||||
base_len + BGFILTER_COLD_START_EXTRA_RETRY_STEPS,
|
||||
true
|
||||
),
|
||||
None
|
||||
);
|
||||
@@ -3402,6 +3460,8 @@ mod tests {
|
||||
async fn connect_retry_exhausts_flat_quota_then_returns_connect_error() {
|
||||
let port = reserved_loopback_port().await;
|
||||
let state = parent_client_state(port);
|
||||
// 标记已连通:本测试验证的是常规档(运行中途故障)的 flat 快速收口配额。
|
||||
state.mark_bgfilter_worker_reached();
|
||||
let audit = parent_audit(None);
|
||||
let started = Instant::now();
|
||||
let error = match request_bgfilter_worker_with_connect_retry(
|
||||
@@ -3535,4 +3595,105 @@ mod tests {
|
||||
// 第一轮连接失败(≤100ms connect timeout)后应立即返回,不做 500ms 空睡。
|
||||
assert!(started.elapsed() < Duration::from_millis(450));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn established_connection_dropped_without_response_is_not_retried() {
|
||||
// 安全不变量:TCP 已被 accept、未返回任何 HTTP 字节即断开 → 结果未知,
|
||||
// 绝不允许第二次连接。用 accept 计数证明父侧没有发起重连,而不只是
|
||||
// 证明错误分类正确。
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind drop-server port");
|
||||
let port = listener.local_addr().expect("local addr").port();
|
||||
let accept_count = Arc::new(AtomicUsize::new(0));
|
||||
let server_count = accept_count.clone();
|
||||
let server = tokio::spawn(async move {
|
||||
loop {
|
||||
let Ok((socket, _)) = listener.accept().await else {
|
||||
break;
|
||||
};
|
||||
server_count.fetch_add(1, AtomicOrdering::SeqCst);
|
||||
drop(socket);
|
||||
}
|
||||
});
|
||||
// 用冷启动档(配额最宽)执行:证明不重连是因为错误分类,而不是配额耗尽。
|
||||
let state = parent_client_state(port);
|
||||
let audit = parent_audit(None);
|
||||
let started = Instant::now();
|
||||
let error = match request_bgfilter_worker_with_connect_retry(
|
||||
&state,
|
||||
"editor/test-object.png",
|
||||
BgfilterBackgroundMode::Complex,
|
||||
None,
|
||||
"test-model",
|
||||
false,
|
||||
Duration::ZERO,
|
||||
&audit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("连接被断开时应失败"),
|
||||
Err(error) => error,
|
||||
};
|
||||
server.abort();
|
||||
assert!(!error.is_connect_failure());
|
||||
assert_eq!(accept_count.load(AtomicOrdering::SeqCst), 1);
|
||||
// 无退避:分类为不可重试后立即返回。
|
||||
assert!(started.elapsed() < Duration::from_millis(450));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cold_start_flat_retries_past_regular_quota_until_worker_listens() {
|
||||
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
|
||||
|
||||
let port = reserved_loopback_port().await;
|
||||
// 不标记已连通:模拟主机开机后 flat 首次调用,worker 约 2.8s 后才监听——
|
||||
// 超出常规档 1.5s 配额,冷启动档必须继续退避跨过窗口。
|
||||
let state = parent_client_state(port);
|
||||
let audit = parent_audit(None);
|
||||
let server = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(2_800)).await;
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port))
|
||||
.await
|
||||
.expect("rebind test port");
|
||||
let (mut socket, _) = listener.accept().await.expect("accept cold-start retry");
|
||||
let mut buffer = [0u8; 4096];
|
||||
// 尽力读走请求头(响应早发也能被 reqwest 处理,无需完整读取 body)。
|
||||
let _ = socket.read(&mut buffer).await;
|
||||
let body = br#"{"error":{"code":"overloaded","message":"test","retryable":true}}"#;
|
||||
let response = format!(
|
||||
"HTTP/1.1 429 Too Many Requests\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n",
|
||||
body.len()
|
||||
);
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("write headers");
|
||||
socket.write_all(body).await.expect("write body");
|
||||
socket.shutdown().await.ok();
|
||||
});
|
||||
let started = Instant::now();
|
||||
let error = match request_bgfilter_worker_with_connect_retry(
|
||||
&state,
|
||||
"editor/test-object.png",
|
||||
BgfilterBackgroundMode::Flat,
|
||||
Some("#00FF00"),
|
||||
"test-model",
|
||||
false,
|
||||
Duration::ZERO,
|
||||
&audit,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("mock 返回 429 时应失败"),
|
||||
Err(error) => error,
|
||||
};
|
||||
server.await.expect("mock server should finish");
|
||||
// 拿到 429 说明冷启动档确实跨过了 2.8s 窗口(常规档 1.5s 内早已放弃)。
|
||||
assert_eq!(error.code(), "overloaded");
|
||||
assert!(!error.is_connect_failure());
|
||||
assert!(started.elapsed() >= Duration::from_millis(2_800));
|
||||
// 首次连通后本进程转入常规档。
|
||||
assert!(state.bgfilter_worker_reached());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ pub struct AppStateInner {
|
||||
#[allow(dead_code)]
|
||||
pub config: AppConfig,
|
||||
ready: AtomicBool,
|
||||
/// 本进程是否已至少一次连通 BgFilter worker(收到任意 HTTP 响应即算)。
|
||||
/// 连接失败重试用它区分冷启动窗口(开机/首连,宽限退避)与运行中途故障(快速收口)。
|
||||
bgfilter_worker_reached: AtomicBool,
|
||||
http_request_permit_pools: HttpRequestPermitPools,
|
||||
auth_jwt_config: JwtConfig,
|
||||
admin_runtime: Option<AdminRuntime>,
|
||||
@@ -532,6 +535,7 @@ impl AppState {
|
||||
Ok(Self(Arc::new(AppStateInner {
|
||||
config,
|
||||
ready: AtomicBool::new(true),
|
||||
bgfilter_worker_reached: AtomicBool::new(false),
|
||||
http_request_permit_pools,
|
||||
auth_jwt_config,
|
||||
admin_runtime,
|
||||
@@ -1292,6 +1296,14 @@ impl AppState {
|
||||
&self.bgfilter_worker_http_client
|
||||
}
|
||||
|
||||
pub fn bgfilter_worker_reached(&self) -> bool {
|
||||
self.bgfilter_worker_reached.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn mark_bgfilter_worker_reached(&self) {
|
||||
self.bgfilter_worker_reached.store(true, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
pub fn bgfilter_image_validation_limiter(&self) -> Arc<Semaphore> {
|
||||
self.bgfilter_image_validation_limiter.clone()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user