diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md index 179f632fb..4a5ee87e3 100644 --- a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -152,7 +152,7 @@ Authorization: Bearer - 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。 - `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。 - `screenColor` 只对 flat 必填;complex 不得误接 flat 参数或熔断。 -- `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 必须等于 worker 本进程按 `N / est` 公式算出的值,不一致按 `invalid_request` 拒绝,用于快速暴露父子配置漂移。 +- `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 是父侧按 `N / est` 公式算出的“配置指纹”,仅作核对:worker 始终以自己按同一公式派生的值执行,不一致时不拒绝请求,而是记录 warn 日志并递增漂移指标。发布调优 N / est 时新旧进程共存的瞬态漂移因此不会误伤在途任务;持久性漂移的硬拦截由部署脚本的共享 env 对齐校验承担。 - JSON body 设置很小的固定上限;源图字节不进入该 JSON。 签名 URL 必须在取得 provider permit 后、每次 attempt 前生成,避免排队期间过期。签名 URL 只存在于子 worker 内存和发往 BgFilter 的请求中。 @@ -262,7 +262,7 @@ attempt 公式的依据:BgFilter 服务端高并发时单图处理约 `1-3s` flat 调用还要由父侧从可分配预算中扣除 `39s`:阿里云 request timeout(默认 `30s`)与本地处理余量 `7s` 构成 `37s` fallback 窗口,另有 `2s` 内部响应传输窗,避免排队吃完全部预算后名义上有 fallback、实际上已无时间执行。 -`N` 与 `est` 必须由父子进程使用同一份有效值:父侧要用它们算 `callBudgetMs` 与 client timeout,worker 要用它们算 attempt 与队列估时。两者都放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖;worker 侧还应校验请求携带的 `callBudgetMs` 与本进程公式值一致,配置漂移时返回 `invalid_request` 快速暴露。 +`N` 与 `est` 必须由父子进程使用同一份有效值:父侧要用它们算 `callBudgetMs` 与 client timeout,worker 要用它们算 attempt 与队列估时。两者都放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖。worker 侧把请求携带的 `callBudgetMs` 与本进程公式值比对,不一致只告警并计指标、仍以本进程值执行——运行时校验必须容忍发布重启窗口内的瞬态漂移,持久漂移由部署脚本对齐校验在启动前拦截。 inline / External v1 当前没有显式 `RequestContext` deadline 时,内部 RPC 仍必须有界:`callBudgetMs` 同公式,`maxQueueWaitMs` 默认取与 `callBudgetMs` 等长的额度,因此默认父 client timeout 为 `2 × callBudgetMs + 2s`;后续若同步请求显式注入 deadline,再按同一 `maxQueueWaitMs` 公式从剩余预算派生。 @@ -405,6 +405,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_in_flight` - `bgfilter_internal_request_seconds{mode,outcome}` - `bgfilter_provider_http_seconds{mode,attempt,outcome}` @@ -443,6 +444,8 @@ BgFilter 成功二进制不是一份新的业务资产: 4. 再重启使用内部 client 的 API / external-generation-worker / controller。 5. 确认所有父进程只访问内部 endpoint,BgFilter provider 日志中不再出现父进程直连。 +第 3-4 步之间存在“新 worker + 旧父进程”共存窗口,恰好覆盖旧 external-generation-worker 的优雅排空阶段。调优 N / est 的发布会让窗口内请求携带旧公式的 `callBudgetMs`:worker 按 §4.1 只告警不拒绝、以自身公式值执行,在途任务照常完成或走既有 fallback。发布期间 `bgfilter_internal_call_budget_drift_total` 短暂增长属预期,窗口结束后应停止增长;持续增长才需要排查 env 漂移。 + 内部 worker 不可用时禁止自动 direct fallback。flat 仍可走业务已有阿里云 / 本地 fallback;complex 明确失败。需要整体回滚时回滚父、子进程版本和配置,不在运行中混用两种 BgFilter 调度方式。 `bgfilter-worker` 收到停止信号后先停止接收新请求,并让仍在排队等待 `N` permit 的请求立即以类型化错误收口(父侧 flat 走 fallback),只等待已取得 permit 的调用和已启动 provider attempt 排空。排空上界由 `callBudgetMs`(`N = 16`、`est = 5s` 时约 `321s`)决定,因此 systemd unit 固定使用 `TimeoutStopSec=900` 仍有充分余量;部署脚本的同步 `systemctl stop` 必须允许该窗口完成,不能沿用 systemd 常见的约 `90s` 默认值强杀在途调用。排队请求不参与排空等待——它们尚未发出任何外部请求,快速失败是安全的。 @@ -461,7 +464,7 @@ BgFilter 成功二进制不是一份新的业务资产: - listener 在 body 解析前执行鉴权与 admission;保险丝 `Q`(默认 `2048`)触达后新请求立即返回 `overloaded`,内核 socket backlog 按独立固定值验证。 - 排队时间只消耗 `min((队长+5)×est×2, maxQueueWaitMs)`,不侵蚀 `callBudgetMs`;排队超时返回 `deadline_exceeded (phase=queue)` 且带触发边界;`callBudgetMs` 自取得 `N` permit 起算,deadline 到达后不开始新的 provider attempt。 - `maxQueueWaitMs <= 0` 时父侧不发送请求:flat 直接 fallback,complex 直接失败。 -- `callBudgetMs` 与 worker 本进程公式值不一致时按 `invalid_request` 拒绝;attempt、callBudget、client timeout 全部由 `N / est` 运行时派生,代码不存在硬编码结果值。 +- `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。 - 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。 @@ -482,7 +485,7 @@ BgFilter 成功二进制不是一份新的业务资产: - 父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和部署 readiness URL 必须指向同一个 `127.0.0.1:` endpoint;旧非空配置不能因为“无需补默认值”而绕过一致性检查。 - `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖 `callBudgetMs` 排空上界(约 `321s`)与停止收口余量;停止时排队请求立即类型化失败,不参与排空。 - 发布目录切换前拒绝缺失、空、符号链接或权限错误的内部 Token 文件;父、子有效 Token 文件路径必须相同。 -- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 `127.0.0.1:8083/readyz` 成功,不能只依赖 systemd 自动重启。 +- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 worker `readyz` 成功(默认 `127.0.0.1:8083`),不能只依赖 systemd 自动重启。provision / deploy 的发布验活 URL 必须从已验证的 env `HOST/PORT` 派生,不得硬编码默认端口;若运维自定义 `GENARRATIVE_BGFILTER_WORKER_PORT`,必须同步覆盖巡检的 `GENARRATIVE_HEALTH_PATROL_BGFILTER_BASE_URL`(巡检是独立进程,不读取 worker env,默认值不会自动跟随)。 - `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。 本地全进程调度门禁使用已构建的 `api-server` binary 和 loopback mock provider,不读取真实 OSS / BgFilter 密钥,也不访问真实外部服务。`load-smoke` 固定验证 `R = 32 / 40 / 48、N = 16`(`Q` 用小值场景单独验证保险丝行为);`fault-smoke` 用独立 worker / mock 生命周期验证保险丝触达快速拒绝、queue timeout 双边界、`503 → 200` 顺序重试、两次 `503` 后 provider exhausted,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功。默认读取 `server-rs/target/debug/api-server(.exe)`;在 WSL 或自定义 target 目录运行时,通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定 binary: diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index ebe953b8e..0777e0950 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -245,7 +245,7 @@ npm run check:server-rs-ddd - 角色动作抠图输入像素边界:仅图片画布角色动作链路在 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 进程级熔断;熔断继续使用 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3` 和 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300` 默认值,但只由子 worker 读写。手动去背景固定使用 `background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;complex 任意失败直接返回父流程失败,不接 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 阶段超时,这类失败不计入 flat 熔断但必须落审计)和父侧阿里云抠图链路已开始后的失败(包括源 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 基础环境,worker 校验请求 `callBudgetMs` 与本进程公式值一致,漂移按 `invalid_request` 拒绝。角色动作不再增加 `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。 +- 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。 - 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/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 613c9ffb7..880f18257 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -768,6 +768,9 @@ validate_bgfilter_loopback_endpoint_alignment() { echo "[server-provision] 父进程 GENARRATIVE_BGFILTER_WORKER_BASE_URL 必须与 BgFilter worker 有效监听地址一致: expected=${expected_base_url}, actual=${base_url:-}" >&2 exit 1 fi + # 验活 URL 从这里已验证的 host/port 派生:校验允许 8083 以外的合法自定义端口, + # 后续 readiness 必须访问同一 endpoint,不能硬编码默认端口造成假阴性。 + BGFILTER_WORKER_READYZ_URL="${expected_base_url}/readyz" } ensure_worker_runtime_env_defaults() { @@ -1259,19 +1262,22 @@ render_bgfilter_worker_service() { } wait_for_bgfilter_worker_service() { + # 真实路径的 URL 由 validate_bgfilter_loopback_endpoint_alignment 从已验证 env 派生; + # dry-run 分支该校验提前返回,此处仅回显默认值。 + local readyz_url="${BGFILTER_WORKER_READYZ_URL:-http://127.0.0.1:8083/readyz}" if [[ "${DRY_RUN}" == "true" ]]; then - echo "+ curl -fsS --max-time 2 http://127.0.0.1:8083/readyz" + echo "+ curl -fsS --max-time 2 ${readyz_url}" return fi - echo "[server-provision] 等待 BgFilter worker readiness。" + echo "[server-provision] 等待 BgFilter worker readiness: ${readyz_url}" for _ in {1..30}; do - if systemctl is-active --quiet genarrative-bgfilter-worker.service && curl -fsS --max-time 2 http://127.0.0.1:8083/readyz >/dev/null; then + if systemctl is-active --quiet genarrative-bgfilter-worker.service && curl -fsS --max-time 2 "${readyz_url}" >/dev/null; then return fi sleep 2 done systemctl --no-pager --full status genarrative-bgfilter-worker.service || true - echo "[server-provision] BgFilter worker 未在超时时间内通过 readiness。" >&2 + echo "[server-provision] BgFilter worker 未在超时时间内通过 readiness: ${readyz_url}" >&2 exit 1 } diff --git a/server-rs/crates/api-server/src/bgfilter_worker.rs b/server-rs/crates/api-server/src/bgfilter_worker.rs index 6405e04f2..ef96144ad 100644 --- a/server-rs/crates/api-server/src/bgfilter_worker.rs +++ b/server-rs/crates/api-server/src/bgfilter_worker.rs @@ -58,6 +58,7 @@ struct BgfilterMetrics { _circuit_state: ObservableGauge, waiting_requests: UpDownCounter, queue_timeout_total: Counter, + call_budget_drift_total: Counter, in_flight: UpDownCounter, internal_request_seconds: Histogram, provider_http_seconds: Histogram, @@ -93,6 +94,13 @@ fn bgfilter_metrics() -> &'static BgfilterMetrics { .with_unit("{request}") .with_description("Queue waits ended by timeout, labeled by which bound fired") .build(), + call_budget_drift_total: meter + .u64_counter("bgfilter_internal_call_budget_drift_total") + .with_unit("{request}") + .with_description( + "Requests whose callBudgetMs fingerprint mismatched the worker formula; transient during deploys, sustained growth means real N/est drift", + ) + .build(), in_flight: meter .i64_up_down_counter("bgfilter_internal_in_flight") .with_unit("{request}") @@ -561,7 +569,8 @@ struct BgfilterInternalRequest { /// 动态队列估时取 min。排队不消耗 callBudget。 max_queue_wait_ms: u64, /// 调用预算:自取得 provider permit 起算,覆盖签名、两次 attempt、校验与响应构造。 - /// 必须等于 worker 本进程按 N/est 公式派生的值,用于快速暴露父子配置漂移。 + /// 该字段是父侧按 N/est 公式算出的配置指纹,仅作核对:worker 始终以自身公式值 + /// 执行,不一致只告警并计漂移指标(容忍发布重启窗口的瞬态漂移)。 call_budget_ms: u64, #[serde(default)] audit_context: Option, @@ -572,7 +581,7 @@ async fn remove_background( Extension(admission): Extension>, payload: Result, JsonRejection>, ) -> Response { - let request = match payload { + let mut request = match payload { Ok(Json(request)) => request, Err(error) => { record_internal_outcome_metrics( @@ -592,11 +601,7 @@ async fn remove_background( ); } }; - if let Err(error) = validate_internal_request( - &request, - &admission, - runtime.app_state.config.bgfilter_call_budget_ms(), - ) { + if let Err(error) = validate_internal_request(&request, &admission) { record_internal_outcome_metrics( request.background_mode.as_str(), "invalid_request", @@ -610,6 +615,26 @@ async fn remove_background( ); } + // 请求携带的 callBudgetMs 只是父侧配置指纹:worker 始终以自身公式值执行。 + // 不一致只告警并计漂移指标——发布调优 N/est 时新旧进程共存的瞬态漂移必然存在, + // 拒绝会让窗口内 flat 任务绕过 fallback(invalid_request 禁止降级); + // 持久漂移由部署脚本的共享 env 对齐校验在启动前拦截。 + let derived_call_budget_ms = runtime.app_state.config.bgfilter_call_budget_ms(); + if request.call_budget_ms != derived_call_budget_ms { + tracing::warn!( + request_id = %admission.request_id, + background_mode = request.background_mode.as_str(), + request_call_budget_ms = request.call_budget_ms, + derived_call_budget_ms, + "bgfilter_call_budget_drift" + ); + bgfilter_metrics().call_budget_drift_total.add( + 1, + &[KeyValue::new("mode", request.background_mode.as_str())], + ); + request.call_budget_ms = derived_call_budget_ms; + } + let Some(task_guard) = runtime.task_tracker.register() else { record_internal_outcome_metrics( request.background_mode.as_str(), @@ -712,7 +737,6 @@ fn status_for_json_rejection(error: &JsonRejection) -> StatusCode { fn validate_internal_request( request: &BgfilterInternalRequest, admission: &AdmissionGuard, - expected_call_budget_ms: u64, ) -> Result<(), WorkerFailure> { if request.request_id != admission.request_id { return Err(WorkerFailure::new( @@ -731,16 +755,6 @@ fn validate_internal_request( false, )); } - if request.call_budget_ms != expected_call_budget_ms { - return Err(WorkerFailure::new( - "invalid_request", - format!( - "callBudgetMs={} 与 worker 按 N×est 公式派生的 {} 不一致,父子 N/est 配置漂移", - request.call_budget_ms, expected_call_budget_ms - ), - false, - )); - } let normalized_object_key = crate::editor_project::normalize_editor_reference_object_key( request.source_object_key.as_str(), ) @@ -2506,7 +2520,6 @@ mod tests { admitted_at: Instant::now(), request_id: "request-1".to_string(), }; - let expected_call_budget_ms = 321_000; let mut request = BgfilterInternalRequest { request_id: "request-1".to_string(), source_object_key: "generated-character-drafts/editor/source.png".to_string(), @@ -2515,21 +2528,22 @@ mod tests { seg_model: "birefnet".to_string(), cross_check: true, max_queue_wait_ms: 1_000, - call_budget_ms: expected_call_budget_ms, + call_budget_ms: 321_000, audit_context: None, }; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); + assert!(validate_internal_request(&request, &admission).is_err()); request.screen_color = Some("#CFEFFF".to_string()); - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_ok()); - // callBudget 与 worker 公式派生值不一致必须按 invalid_request 拒绝(父子配置漂移)。 - request.call_budget_ms = expected_call_budget_ms + 1; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); - request.call_budget_ms = expected_call_budget_ms; + assert!(validate_internal_request(&request, &admission).is_ok()); + // callBudget 只是父侧配置指纹:与 worker 公式值不一致不得拒绝(发布重启窗口 + // 的瞬态漂移必然存在),由 handler 告警并以 worker 自身公式值执行。 + request.call_budget_ms = 1; + assert!(validate_internal_request(&request, &admission).is_ok()); + request.call_budget_ms = 321_000; // 排队预算必须为正且不超过荒谬值防线。 request.max_queue_wait_ms = 0; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); + assert!(validate_internal_request(&request, &admission).is_err()); request.max_queue_wait_ms = BGFILTER_MAX_QUEUE_WAIT_MS + 1; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); + assert!(validate_internal_request(&request, &admission).is_err()); request.max_queue_wait_ms = 1_000; for malicious in [ "/generated-character-drafts/editor/source.png", @@ -2543,21 +2557,21 @@ mod tests { ] { request.source_object_key = malicious.to_string(); assert!( - validate_internal_request(&request, &admission, expected_call_budget_ms).is_err(), + validate_internal_request(&request, &admission).is_err(), "malicious object key should fail: {malicious}" ); } request.source_object_key = "generated-character-drafts/editor/source.png".to_string(); request.background_mode = BgfilterBackgroundMode::Complex; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); + assert!(validate_internal_request(&request, &admission).is_err()); request.screen_color = None; request.cross_check = false; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_ok()); + assert!(validate_internal_request(&request, &admission).is_ok()); request.seg_model = "anime-seg".to_string(); - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); + assert!(validate_internal_request(&request, &admission).is_err()); request.seg_model = "birefnet".to_string(); request.cross_check = true; - assert!(validate_internal_request(&request, &admission, expected_call_budget_ms).is_err()); + assert!(validate_internal_request(&request, &admission).is_err()); } #[test]