diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 2ed76b5e8..752a0871e 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -227,6 +227,8 @@ 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。 + queue job 的总预算从父 job 开始执行时起算,不从开始申请 BgFilter 时重新计时。现有父 worker 还会把 provider deadline 设在 job deadline 前 `60s`,为最终写回和终态保留时间。同步 RPC 实现必须显式读取父侧剩余 provider budget 派生 `maxQueueWaitMs`,不能忽略 `RequestContext` deadline。 本版没有 BgFilter 子任务等待 claim 的阶段。几个起算点必须区分:父 job 在数据库中尚未被 claim 的等待不消耗 job 执行预算;父 job 开始实际执行后,生图及 BgFilter 之前的耗时都会消耗父总预算;父内部 HTTP client timeout 从开始发送请求起覆盖 loopback 传输、worker admission、排队、provider 和回包;`callBudgetMs` 计时只在子 worker 取得 provider permit 后启动,attempt timer 只在真正开始一次 provider HTTP 时启动。父侧不是放弃超时,而是不再直接执行 provider 单次 attempt 的计时器。 @@ -335,7 +337,7 @@ flat / complex 统一使用的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRE ### 7.1 内部 RPC 断连 -- 父侧不得自动重试整次内部 HTTP。断连时结果未知,重试可能让一次逻辑调用从最多两次 provider attempt 扩大为四次,并可能突破瞬时并发预期。 +- 连接已建立后的断连不得自动重试整次内部 HTTP:此时结果未知,重试可能让一次逻辑调用从最多两次 provider attempt 扩大为四次,并可能突破瞬时并发预期。唯一例外是 TCP 连接从未建立的失败(连接拒绝 / 不可达 / connect 阶段超时):请求从未进入 worker admission,结果确定为「未发生」,父侧按 §5.1 的预算约束有界退避重试,用于跨过 worker 重启与主机开机排序窗口;收到任何 HTTP 响应后即回到本条禁令。 - 尚在等待 permit 的请求到达自身 deadline 后必须取消,不再发送 provider 请求;运行时若能可靠观察客户端断连,也可提前取消,但正确性不能只依赖断连事件。 - 已经开始的 provider attempt 必须继续读取到完成或本次 attempt timeout,并持有 permit;可观察到的 handler / client drop 只丢弃最终结果,不能让已启动请求变成无人管理的本地 future。 - deadline 已被子 worker 观察到后,不再开始第二次 attempt。首版没有显式 cancellation signal 通道;单纯 TCP 断连只能 best-effort 阻止二试(handler future 被 drop 后自然不再开始新 attempt),Axum / Hyper 不保证立刻通知 handler,因此不能承诺所有断连都阻止二试,排队阶段的 queue timeout 与 permit 后的 `callBudgetMs` deadline 是最终可靠的停止条件。 @@ -345,7 +347,7 @@ flat / complex 统一使用的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRE ### 7.2 进程崩溃 - 父进程崩溃:内部连接最终断开,父 job 按现有 heartbeat、lease、`max_attempts = 1`、失败和退款语义收口。 -- 子 worker 崩溃或重启:当前内部 RPC 失败;父侧不查询、不恢复、不重发同一次 RPC。 +- 子 worker 崩溃或重启:已在途的内部 RPC 失败;父侧不查询、不恢复、不重发同一次 RPC。重启窗口内连接从未建立的新调用按 §7.1 的例外有界重试。 - 子 worker 成功但响应在网络中丢失:结果视为未知;flat 进入原 fallback,complex 失败。 - 子 worker 不得反向 complete / fail 父 job,也不得写画布、业务资源或账单。 @@ -406,6 +408,7 @@ BgFilter 成功二进制不是一份新的业务资产: - `bgfilter_internal_waiting_requests`(即 admission 后等待 `N` permit 的队长) - `bgfilter_internal_queue_timeout_total{bound}`(排队超时按触发边界 `estimate | parent` 分维度) - `bgfilter_internal_call_budget_drift_total{mode}`(请求 `callBudgetMs` 与本进程公式值不一致;发布窗口内短暂非零正常,持续增长说明父子 N / est 真漂移) +- `bgfilter_internal_connect_retry_total{mode}`(父侧连接失败重试次数;worker 重启窗口内短暂非零正常,持续增长说明 worker 长期不可达) - `bgfilter_internal_in_flight` - `bgfilter_internal_request_seconds{mode,outcome}` - `bgfilter_provider_http_seconds{mode,attempt,outcome}` @@ -466,7 +469,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。 +- 第一次失败后预算不足时不开始第二次;父侧从不重试已建立连接的内部 RPC。连接从未建立的失败按 §5.1 有界退避重试:仅 connect 类失败重入、收到任何 HTTP 响应立即停止、每轮重算 `maxQueueWaitMs`、complex / flat 各自的退避配额封顶(Rust 集成测试覆盖「重试跨过监听空窗后停在首个 HTTP 响应」「配额耗尽返回 connect 失败」「deadline 放不下下一轮时不空睡」三条路径)。 - 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败或自身熔断都直接失败,不接 flat fallback。 - flat / complex 分别按自身真实失败 attempt 计数且状态互不影响;由剩余业务预算截短的 timeout 不计入。两种模式都在 permit 前二次检查;已获准调用可完成第二次,后续同模式排队请求快速 `circuit_open`。 - `cancelled`(仅验证父侧映射,保留码首版不产生)、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 30931de2f..e29332880 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -244,8 +244,8 @@ npm run check:server-rs-ddd - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 -- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 与 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,默认 base URL 为 `http://58.87.105.82/bgfilter`;单次 provider attempt 上限不再独立配置,由公式 `N × est × 2` 运行时派生,其中 `est = GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS`(默认 `5000`,依据为服务端高并发单图处理约 1-3s、网络约 3-5s),旧 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 已删除;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission 保险丝 `Q`(默认 `2048`,仅防连接风暴)、provider 并发 `N`(生产 `16`);排队 deadline 从 `Q` admission 时刻起算,完成 JSON 校验并进入 provider permit 等待队列时再取得队长快照,按 `min((队长+5)×est×2, maxQueueWaitMs)` 约束排队等待。子 worker 还负责严格最多两次顺序 attempt、结果校验和按 flat / complex 隔离的进程级熔断;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式,且只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex provider 失败累计自身熔断,任意失败或自身熔断都直接返回父流程失败,不接 flat fallback,也不影响 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。父侧不重试整次内部 RPC;flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败(含被剩余预算截短后发生的 timeout 与 response 阶段超时,这类失败不计入熔断但必须落审计)和父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 -- BgFilter 连接复用、超时与动作帧流水线:`AppState` 分别复用父侧内部 worker HTTP Client 和子 worker 专用 BgFilter provider HTTP Client;父侧对一次逻辑调用只发送一次内部 RPC,不自动重试,子 worker 在同一个 `N` permit 内严格最多执行两次顺序 provider attempt。唯一子 worker 使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=N`(生产 `16`)限制真实 provider 在途数;`GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=Q` 降级为可选 admission 保险丝(默认 `2048`,仅防连接风暴,显式配置时必须 `>= N`)。超时全部由 `N` 与 `est` 运行时派生:单 attempt 上限 `N × est × 2`、调用预算 `callBudgetMs = 2 × attempt + 1s`(自取得 `N` permit 起算)、排队等待受 `min((provider 等待队列队长+5)×est×2, maxQueueWaitMs)` 双重上界(动态项充当自适应过载探测,超时带 `bound = estimate | parent` 标记),排队不侵蚀调用预算;`N` 与 `est` 必须同放共享 API 基础环境;请求携带的 `callBudgetMs` 只是父侧配置指纹,worker 比对后不一致只告警并计 `bgfilter_internal_call_budget_drift_total` 指标、始终以本进程公式值执行——发布调优 N / est 的新旧进程共存窗口不得误伤在途任务,持久漂移由部署脚本共享 env 对齐校验在启动前拦截。角色动作不再增加 `2000ms × 本次实际帧数`,`32 / 40 / 48` 帧使用相同公式。父侧按剩余绝对预算派生 `maxQueueWaitMs`(flat 扣除 `39s` 父侧预留(`37s` fallback + `2s` 传输窗),complex 只留 `2s` 传输窗;`<= 0` 时不发请求直接降级 / 失败),client timeout 取 `maxQueueWaitMs + callBudgetMs + 2s`;每次 attempt 前重新签发短期 OSS URL,剩余时间不足时不开始新的 attempt。父侧成功响应解码槽 `P = 8`。角色动作继续以 `buffer_unordered(frame_count.max(1))` 将全部单帧逻辑调用加入无序在途集合;返回结果携带原始帧序并在最终 collect / drain 全部已提交 Future 后排序,任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。单帧按“绿幕源图 owned 上传 OSS 并释放原帧 → 以 object key 调内部 worker / 按 object key 由父侧降级 → 父侧处理透明帧并落 OSS”流水化。角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 仍统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制;BgFilter provider 的 `N` 不占该 OSS permit,阿里云和本地处理既不占 OSS permit,也不受 `N / Q` 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。最终帧 HEAD 失败只重试 HEAD,不重复 PUT。 +- 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一通过唯一 loopback `bgfilter-worker` 调用 BgFilter provider。provider 配置继续使用 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL` 与 `GENARRATIVE_EDITOR_BGFILTER_TOKEN`,默认 base URL 为 `http://58.87.105.82/bgfilter`;单次 provider attempt 上限不再独立配置,由公式 `N × est × 2` 运行时派生,其中 `est = GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS`(默认 `5000`,依据为服务端高并发单图处理约 1-3s、网络约 3-5s),旧 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 已删除;旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 provider token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。父流程先把候选 `objectKey`、`resourceId` 或 `assetId` 解析为当前 owner 已登记的私有 OSS object key;BFF 入队前统一拒绝 `data:` / `blob:`,底层 resolver 在解析引用前再次拒绝内联媒体并完成登记状态与 owner 校验。父流程只通过一次内部 HTTP RPC 传递 object key、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 与模式参数,不传图片字节或签名 URL,并同步等待子 worker 返回的受限图片二进制 body。子 worker 在每次真实 provider attempt 前签发短期 OSS URL,承担 admission 保险丝 `Q`(默认 `2048`,仅防连接风暴)、provider 并发 `N`(生产 `16`);排队 deadline 从 `Q` admission 时刻起算,完成 JSON 校验并进入 provider permit 等待队列时再取得队长快照,按 `min((队长+5)×est×2, maxQueueWaitMs)` 约束排队等待。子 worker 还负责严格最多两次顺序 attempt、结果校验和按 flat / complex 隔离的进程级熔断;两种模式共享 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=120` 默认值,但失败和成功只更新当前模式,且只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex provider 失败累计自身熔断,任意失败或自身熔断都直接返回父流程失败,不接 flat fallback,也不影响 flat 熔断。标准纯色背景四条链路固定使用 `background_mode=flat`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。父侧不重试已建立连接的内部 RPC,仅对 TCP 连接从未建立的失败按父预算有界退避重试(跨过 worker 重启与开机排序窗口,收到任何 HTTP 响应即停止);flat 两次 provider attempt 失败、熔断、overload、内部 deadline 或断连后,只要父业务预算仍有效,父流程才继续“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以 object key 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,子 worker 已发出的 BgFilter provider 失败(含被剩余预算截短后发生的 timeout 与 response 阶段超时,这类失败不计入熔断但必须落审计)和父侧阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。成功图片字节返回后,最终 Alpha / 尺寸恢复、OSS / asset object、画布写回、计费和父任务终态仍全部由父流程负责。 +- BgFilter 连接复用、超时与动作帧流水线:`AppState` 分别复用父侧内部 worker HTTP Client 和子 worker 专用 BgFilter provider HTTP Client;父侧对一次逻辑调用只发送一次内部 RPC,不重试已建立连接后的失败;唯一例外是 TCP 连接从未建立的失败(worker 重启 / 开机排序窗口),按每轮重算 `maxQueueWaitMs` 的有界退避序列重试(complex 总额约 22.5s、flat ≤2 次),并计 `bgfilter_internal_connect_retry_total` 指标。子 worker 在同一个 `N` permit 内严格最多执行两次顺序 provider attempt。唯一子 worker 使用 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=N`(生产 `16`)限制真实 provider 在途数;`GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=Q` 降级为可选 admission 保险丝(默认 `2048`,仅防连接风暴,显式配置时必须 `>= N`)。超时全部由 `N` 与 `est` 运行时派生:单 attempt 上限 `N × est × 2`、调用预算 `callBudgetMs = 2 × attempt + 1s`(自取得 `N` permit 起算)、排队等待受 `min((provider 等待队列队长+5)×est×2, maxQueueWaitMs)` 双重上界(动态项充当自适应过载探测,超时带 `bound = estimate | parent` 标记),排队不侵蚀调用预算;`N` 与 `est` 必须同放共享 API 基础环境;请求携带的 `callBudgetMs` 只是父侧配置指纹,worker 比对后不一致只告警并计 `bgfilter_internal_call_budget_drift_total` 指标、始终以本进程公式值执行——发布调优 N / est 的新旧进程共存窗口不得误伤在途任务,持久漂移由部署脚本共享 env 对齐校验在启动前拦截。角色动作不再增加 `2000ms × 本次实际帧数`,`32 / 40 / 48` 帧使用相同公式。父侧按剩余绝对预算派生 `maxQueueWaitMs`(flat 扣除 `39s` 父侧预留(`37s` fallback + `2s` 传输窗),complex 只留 `2s` 传输窗;`<= 0` 时不发请求直接降级 / 失败),client timeout 取 `maxQueueWaitMs + callBudgetMs + 2s`;每次 attempt 前重新签发短期 OSS URL,剩余时间不足时不开始新的 attempt。父侧成功响应解码槽 `P = 8`。角色动作继续以 `buffer_unordered(frame_count.max(1))` 将全部单帧逻辑调用加入无序在途集合;返回结果携带原始帧序并在最终 collect / drain 全部已提交 Future 后排序,任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。单帧按“绿幕源图 owned 上传 OSS 并释放原帧 → 以 object key 调内部 worker / 按 object key 由父侧降级 → 父侧处理透明帧并落 OSS”流水化。角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 仍统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制;BgFilter provider 的 `N` 不占该 OSS permit,阿里云和本地处理既不占 OSS permit,也不受 `N / Q` 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。最终帧 HEAD 失败只重试 HEAD,不重复 PUT。 - Match3D 物品 sheet:关卡整图完成后走 VectorEngine `/v1/images/edits` multipart `image`,模型为 `gpt-image-2`,`2K 1:1` 输出 `10*10` spritesheet;物品 sheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG,并把透明整图写入 `itemSpritesheetImageSrc/itemSpritesheetImageObjectKey`。后端优先按透明 alpha 连通域从该 sheet 识别真实素材矩形并持久化 20 个物品、每个 5 个形态;识别数量不足时才回退 `10*10` 固定网格。通用系列素材图集的行列索引按每行 2 个物品计算,必须落在 `1..=10`,难度只决定运行态加载 3 / 9 / 15 / 20 种。 - Match3D UI spritesheet 和背景派生图:关卡整图作为参考图并发生成 `1K 1:1` UI spritesheet 与 `1K 9:16` 背景图,模型均为 `gpt-image-2`。UI spritesheet prompt 固定要求单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景,后端上传 OSS 前必须把绿幕扣成透明 PNG;背景图必须合成为全画幅不透明 PNG。 - Match3D 1:1 容器 UI:VectorEngine `/v1/images/edits` multipart 参考图。该容器参考图是后端生图协议输入,必须通过 `include_bytes!` 随 `api-server` 编译进二进制,避免 API 单独发布或运行目录缺少 `public/` 时生成失败。 diff --git a/server-rs/crates/api-server/src/bgfilter_worker.rs b/server-rs/crates/api-server/src/bgfilter_worker.rs index 905c46049..cb967a9a9 100644 --- a/server-rs/crates/api-server/src/bgfilter_worker.rs +++ b/server-rs/crates/api-server/src/bgfilter_worker.rs @@ -51,6 +51,13 @@ const BGFILTER_PROVIDER_ATTEMPT_RESERVE: Duration = Duration::from_secs(1); const BGFILTER_INTERNAL_CLIENT_RESPONSE_RESERVE: Duration = Duration::from_secs(2); const BGFILTER_SOURCE_URL_EXPIRE_SECONDS: u64 = 600; const BGFILTER_PROVIDER_TOKEN_HEADER: &str = "X-Genarrative-Image-Token"; +/// 连接失败(TCP 从未建立)的重试退避序列。总长约 22.5s,按上界覆盖 +/// worker 的 systemd 自动拉起窗口(RestartSec=5s + 进程启动)与主机重启的 +/// 并发启动窗口;更长的 worker 故障属于真实停机,应当快速失败而不是挂住父 job。 +const BGFILTER_CONNECT_RETRY_BACKOFF_MS: [u64; 7] = [500, 1_000, 2_000, 4_000, 5_000, 5_000, 5_000]; +/// flat 有完整 fallback 链,重试只为跨过最短窗口,不得侵蚀 fallback 预留: +/// 只取退避序列前 2 项(额外延迟 ≤1.5s)。 +const BGFILTER_FLAT_CONNECT_RETRY_LIMIT: usize = 2; static BGFILTER_FLAT_CIRCUIT: OnceLock> = OnceLock::new(); static BGFILTER_COMPLEX_CIRCUIT: OnceLock> = OnceLock::new(); @@ -60,6 +67,7 @@ struct BgfilterMetrics { waiting_requests: UpDownCounter, queue_timeout_total: Counter, call_budget_drift_total: Counter, + connect_retry_total: Counter, in_flight: UpDownCounter, internal_request_seconds: Histogram, provider_http_seconds: Histogram, @@ -110,6 +118,13 @@ fn bgfilter_metrics() -> &'static BgfilterMetrics { "Requests whose callBudgetMs fingerprint mismatched the worker formula; transient during deploys, sustained growth means real N/est drift", ) .build(), + connect_retry_total: meter + .u64_counter("bgfilter_internal_connect_retry_total") + .with_unit("{attempt}") + .with_description( + "Parent-side retries after the worker TCP connect failed; transient spikes cover worker restarts, sustained growth means the worker is down", + ) + .build(), in_flight: meter .i64_up_down_counter("bgfilter_internal_in_flight") .with_unit("{request}") @@ -171,6 +186,9 @@ pub(crate) struct BgfilterClientError { status: StatusCode, timeout: bool, transport: bool, + /// TCP 连接从未建立(拒绝/不可达/connect 阶段失败):请求未进入 worker + /// 准入队列,无副作用,是唯一可以安全自动重试的失败类别。 + connect: bool, } impl BgfilterClientError { @@ -184,6 +202,10 @@ impl BgfilterClientError { !matches!(self.code, "invalid_request" | "unauthorized" | "cancelled") } + fn is_connect_failure(&self) -> bool { + self.connect + } + pub(crate) fn into_app_error(self) -> AppError { let status = match self.code { "invalid_request" => StatusCode::BAD_REQUEST, @@ -210,6 +232,7 @@ impl BgfilterClientError { status: StatusCode::BAD_GATEWAY, timeout: code == "deadline_exceeded", transport: false, + connect: false, } } } @@ -2065,6 +2088,95 @@ fn worker_image_response(image: BgfilterImage, request_id: &str) -> Response { response } +/// 决定第 `attempt` 次连接失败后是否还允许重试及退避时长;`None` 表示配额用尽。 +fn connect_retry_backoff(mode: BgfilterBackgroundMode, attempt: usize) -> Option { + let limit = match mode { + BgfilterBackgroundMode::Flat => BGFILTER_FLAT_CONNECT_RETRY_LIMIT, + BgfilterBackgroundMode::Complex => BGFILTER_CONNECT_RETRY_BACKOFF_MS.len(), + }; + if attempt >= limit { + return None; + } + BGFILTER_CONNECT_RETRY_BACKOFF_MS + .get(attempt) + .copied() + .map(Duration::from_millis) +} + +/// 围绕 `request_bgfilter_worker` 的连接失败有界重试:覆盖 worker 重启与主机 +/// 开机排序窗口。只重试 TCP 连接从未建立的失败(请求未进入 worker 准入队列, +/// 无副作用、天然幂等);收到任何 HTTP 响应(含 5xx)或其它错误类别都按原语义 +/// 立刻返回。每轮从父剩余预算按现有公式重算 `maxQueueWaitMs`,重试消耗的是 +/// 预算自然余量,不突破「预算不足不发送」的协议不变量;退避序列本身有界, +/// 父无绝对 deadline 时也不会无限等待。 +pub(crate) async fn request_bgfilter_worker_with_connect_retry( + state: &AppState, + source_object_key: &str, + background_mode: BgfilterBackgroundMode, + screen_color: Option<&str>, + seg_model: &str, + cross_check: bool, + deadline_reserve: Duration, + audit: &ExternalApiAuditContext, +) -> Result { + let call_budget_ms = state.config.bgfilter_call_budget_ms(); + let mut connect_failures = 0usize; + loop { + let max_queue_wait_ms = max_queue_wait_ms( + call_budget_ms, + audit.external_call_deadline, + deadline_reserve, + ); + let error = match request_bgfilter_worker( + state, + source_object_key, + background_mode, + screen_color, + seg_model, + cross_check, + max_queue_wait_ms, + audit, + ) + .await + { + Ok(image) => return Ok(image), + Err(error) => error, + }; + if !error.is_connect_failure() { + return Err(error); + } + let Some(backoff) = connect_retry_backoff(background_mode, connect_failures) else { + return Err(error); + }; + if let Some(deadline) = audit.external_call_deadline { + // 睡完还得放得下 reserve + 一次完整调用,否则下一轮 maxQueueWait 必为 0, + // 与其空睡不如立刻按当前错误返回。 + let next_round_floor = backoff + .saturating_add(deadline_reserve) + .saturating_add(Duration::from_millis(call_budget_ms)); + if deadline + .checked_duration_since(Instant::now()) + .unwrap_or(Duration::ZERO) + < next_round_floor + { + return Err(error); + } + } + connect_failures += 1; + tracing::warn!( + background_mode = background_mode.as_str(), + connect_failures, + backoff_ms = duration_millis(backoff), + error = %error.message, + "bgfilter_worker_connect_retry" + ); + bgfilter_metrics() + .connect_retry_total + .add(1, &[KeyValue::new("mode", background_mode.as_str())]); + tokio::time::sleep(backoff).await; + } +} + pub(crate) async fn request_bgfilter_worker( state: &AppState, source_object_key: &str, @@ -2128,6 +2240,7 @@ pub(crate) async fn request_bgfilter_worker( status: StatusCode::BAD_GATEWAY, timeout: error.is_timeout(), transport: true, + connect: error.is_connect(), })?; let status = response.status(); if !status.is_success() { @@ -2175,6 +2288,7 @@ pub(crate) async fn request_bgfilter_worker( status: StatusCode::BAD_GATEWAY, timeout, transport: true, + connect: false, }); } Err(_) => { @@ -2227,6 +2341,7 @@ pub(crate) async fn request_bgfilter_worker( status: StatusCode::BAD_GATEWAY, timeout: false, transport: false, + connect: false, }) .map(|image| { tracing::info!( @@ -2345,6 +2460,7 @@ async fn read_worker_error( status: StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), timeout: code == "deadline_exceeded", transport: false, + connect: false, }) } @@ -3152,6 +3268,10 @@ mod tests { .0; assert_eq!(parent_client.matches(".send()").count(), 1); assert!(!parent_client.contains("for attempt")); + // 连接失败重试围绕唯一的 .send() 调用点循环:仅 TCP 从未建立的失败重入, + // 收到任何 HTTP 响应都按原语义立刻返回。 + assert!(parent_client.contains("error.is_connect_failure()")); + assert!(parent_client.contains("connect: error.is_connect()")); } #[test] @@ -3212,4 +3332,207 @@ mod tests { ); assert_eq!(stable_error_code("unknown"), None); } + + #[test] + fn connect_retry_backoff_is_bounded_per_mode() { + // flat 只取前 2 项:额外延迟 ≤1.5s,不侵蚀 39s fallback 预留。 + assert_eq!( + connect_retry_backoff(BgfilterBackgroundMode::Flat, 0), + Some(Duration::from_millis(500)) + ); + assert_eq!( + connect_retry_backoff(BgfilterBackgroundMode::Flat, 1), + 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()) + .map(|attempt| { + connect_retry_backoff(BgfilterBackgroundMode::Complex, attempt) + .expect("complex 应可用完整退避序列") + .as_millis() as u64 + }) + .sum(); + assert_eq!(total, 22_500); + assert_eq!( + connect_retry_backoff( + BgfilterBackgroundMode::Complex, + BGFILTER_CONNECT_RETRY_BACKOFF_MS.len() + ), + None + ); + } + + fn parent_client_state(port: u16) -> AppState { + let mut config = AppConfig::default(); + config.bgfilter_internal_token = Some("parent-test-token".to_string()); + config.bgfilter_worker_base_url = format!("http://127.0.0.1:{port}"); + // Windows 对已关闭的 loopback 端口不回 RST 而是挂到 connect timeout; + // 调小超时让「连接失败」在两个平台上都以可控节奏出现。 + config.bgfilter_worker_connect_timeout_ms = 100; + AppState::new(config).expect("test state should build") + } + + fn parent_audit(deadline: Option) -> ExternalApiAuditContext { + ExternalApiAuditContext { + user_id: None, + profile_id: None, + request_id: None, + external_call_deadline: deadline, + } + } + + async fn reserved_loopback_port() -> u16 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral port"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + port + } + + fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) + } + + #[tokio::test] + async fn connect_retry_exhausts_flat_quota_then_returns_connect_error() { + let port = reserved_loopback_port().await; + 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::Flat, + Some("#00FF00"), + "test-model", + false, + Duration::ZERO, + &audit, + ) + .await + { + Ok(_) => panic!("端口关闭时应失败"), + Err(error) => error, + }; + assert!(error.is_connect_failure()); + // Linux 即时拒绝 → internal_error;Windows 挂到 connect timeout → deadline_exceeded。 + // 重试判定只看 is_connect_failure,code 允许两种平台形态。 + assert!(matches!( + error.code(), + "internal_error" | "deadline_exceeded" + )); + // flat 配额 2 次重试对应 500ms + 1000ms 退避:证明确实退避过而不是立即放弃。 + assert!(started.elapsed() >= Duration::from_millis(1_400)); + } + + #[tokio::test] + async fn connect_retry_crosses_worker_restart_window_and_stops_on_http_response() { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let port = reserved_loopback_port().await; + let state = parent_client_state(port); + let audit = parent_audit(None); + let server = tokio::spawn(async move { + // 模拟 worker 重启窗口:先保持端口关闭,约 700ms 后才开始监听。 + tokio::time::sleep(Duration::from_millis(700)).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 parent retry"); + let mut request = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let read = socket.read(&mut buffer).await.expect("read request"); + if read == 0 { + break; + } + request.extend_from_slice(&buffer[..read]); + if let Some(headers_end) = find_subslice(&request, b"\r\n\r\n") { + let headers = String::from_utf8_lossy(&request[..headers_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + value.trim().parse::().ok() + } else { + None + } + }) + .unwrap_or(0); + if request.len() >= headers_end + 4 + content_length { + break; + } + } + } + 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::Complex, + None, + "test-model", + false, + Duration::ZERO, + &audit, + ) + .await + { + Ok(_) => panic!("mock 返回 429 时应失败"), + Err(error) => error, + }; + server.await.expect("mock server should finish"); + // 收到 HTTP 响应(429 → overloaded)即停止重试:错误按原语义返回,不再是 connect 失败。 + assert_eq!(error.code(), "overloaded"); + assert!(!error.is_connect_failure()); + // 至少经历一次退避才可能跨过 700ms 的监听空窗。 + assert!(started.elapsed() >= Duration::from_millis(700)); + } + + #[tokio::test] + async fn connect_retry_stops_without_sleeping_when_deadline_cannot_fit_next_round() { + let port = reserved_loopback_port().await; + let state = parent_client_state(port); + let call_budget = Duration::from_millis(state.config.bgfilter_call_budget_ms()); + // 父剩余刚好放得下第一次调用(约 300ms 排队额度),放不下「退避 + 再一次完整调用」。 + let audit = parent_audit(Some( + Instant::now() + call_budget + Duration::from_millis(300), + )); + 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, + }; + assert!(error.is_connect_failure()); + // 第一轮连接失败(≤100ms connect timeout)后应立即返回,不做 500ms 空睡。 + assert!(started.elapsed() < Duration::from_millis(450)); + } } diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index c47bef59e..144b94149 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -3386,20 +3386,16 @@ async fn request_editor_background_removal_image_with_bgfilter_worker( source_object_key: &str, audit: &crate::external_api_audit::ExternalApiAuditContext, ) -> Result { - // complex 没有 flat fallback,排队上限只预留 2s 传输窗。 - let max_queue_wait_ms = crate::bgfilter_worker::max_queue_wait_ms( - state.config.bgfilter_call_budget_ms(), - audit.external_call_deadline, - EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, - ); - let removed = crate::bgfilter_worker::request_bgfilter_worker( + // complex 没有 flat fallback,排队上限只预留 2s 传输窗;worker 重启窗口内的 + // 连接失败由 client 内部按预算有界重试,避免 max_attempts=1 的队列任务终态失败。 + let removed = crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry( state, source_object_key, crate::bgfilter_worker::BgfilterBackgroundMode::Complex, None, EDITOR_BGFILTER_DEFAULT_SEG_MODEL, EDITOR_BGFILTER_CROSS_CHECK_DISABLED, - max_queue_wait_ms, + EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW, audit, ) .await @@ -3431,20 +3427,16 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter( ) -> Result { // flat 的排队上限从父剩余预算里预扣「阿里云 + 本地 + 传输」的 fallback 时间, // 保证排队吃不完 fallback 的执行窗口;上限为 0 时 client 直接返回 - // deadline_exceeded,本函数随即进入 fallback 链。 - let max_queue_wait_ms = crate::bgfilter_worker::max_queue_wait_ms( - state.config.bgfilter_call_budget_ms(), - audit.external_call_deadline, - editor_bgfilter_flat_deadline_reserve(state.config.aliyun_matting_request_timeout_ms), - ); - match crate::bgfilter_worker::request_bgfilter_worker( + // deadline_exceeded,本函数随即进入 fallback 链。worker 重启窗口内的连接 + // 失败由 client 先做小额重试(≤2 次),仍失败才降级,避免不必要的质量损失。 + match crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry( state, source_object_key, crate::bgfilter_worker::BgfilterBackgroundMode::Flat, Some(screen_color.hex), seg_model, cross_check, - max_queue_wait_ms, + editor_bgfilter_flat_deadline_reserve(state.config.aliyun_matting_request_timeout_ms), audit, ) .await @@ -10625,11 +10617,10 @@ mod tests { "pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(", "async fn fallback_editor_screen_background_removal", &[ - "crate::bgfilter_worker::max_queue_wait_ms", - "state.config.bgfilter_call_budget_ms()", - "editor_bgfilter_flat_deadline_reserve", - "crate::bgfilter_worker::request_bgfilter_worker", + "crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry", "crate::bgfilter_worker::BgfilterBackgroundMode::Flat", + "editor_bgfilter_flat_deadline_reserve", + "state.config.aliyun_matting_request_timeout_ms", "error.allows_flat_fallback()", "fallback_editor_screen_background_removal", ], @@ -10662,12 +10653,11 @@ mod tests { "async fn request_editor_background_removal_image_with_bgfilter_worker", "pub(crate) struct EditorScreenBackgroundRemovalOutput", &[ - "crate::bgfilter_worker::max_queue_wait_ms", - "state.config.bgfilter_call_budget_ms()", - "crate::bgfilter_worker::request_bgfilter_worker", + "crate::bgfilter_worker::request_bgfilter_worker_with_connect_retry", "crate::bgfilter_worker::BgfilterBackgroundMode::Complex", "EDITOR_BGFILTER_DEFAULT_SEG_MODEL", "EDITOR_BGFILTER_CROSS_CHECK_DISABLED", + "EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW", ".map_err(crate::bgfilter_worker::BgfilterClientError::into_app_error)?", ], );