重构 BgFilter 排队与调用双预算
Project CI / Native shell tests (pull_request) Successful in 7m50s
Project CI / Frontend tests (pull_request) Successful in 1m46s
Project CI / Repository checks (pull_request) Failing after 6m46s
Project CI / Backend tests (pull_request) Failing after 6m6s

按 N 与单图估时公式化派生 provider attempt、调用预算和队列等待上限

将内部协议拆分为 maxQueueWaitMs 与 callBudgetMs,并补齐停机、熔断和审计语义

同步部署配置、运维门禁、smoke 测试及权威文档
This commit is contained in:
2026-07-22 08:41:59 +00:00
parent 27f84fc6a7
commit 2f8019c901
28 changed files with 1084 additions and 418 deletions
+3 -2
View File
@@ -151,8 +151,9 @@ GENARRATIVE_BGFILTER_WORKER_HOST="127.0.0.1"
GENARRATIVE_BGFILTER_WORKER_PORT="8083"
GENARRATIVE_BGFILTER_WORKER_BASE_URL="http://127.0.0.1:8083"
GENARRATIVE_BGFILTER_INTERNAL_TOKEN="CHANGE_ME_FOR_LOCAL"
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY="4"
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS="128"
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY="16"
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS="5000"
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS="2048"
GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS="2000"
# SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。
+2 -1
View File
@@ -30,7 +30,8 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox
GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100
GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=16
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=5000
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300
# BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层,
+4 -2
View File
@@ -34,8 +34,10 @@ GENARRATIVE_WALLET_REFUND_OUTBOX_DIR=/var/lib/genarrative/wallet-refund-outbox
GENARRATIVE_WALLET_REFUND_OUTBOX_BATCH_SIZE=100
GENARRATIVE_WALLET_REFUND_OUTBOX_FLUSH_INTERVAL_MS=1000
GENARRATIVE_WALLET_REFUND_OUTBOX_MAX_BYTES=67108864
# 共享的单次 provider attempt 超时上限,父侧据此计算两次逻辑预算;专属 worker env 不重复定义。
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000
# N 与单图估时:父子共同派生 attempt = N×est×2、callBudget = 2×attempt+1s 的公式输入;
# 单一来源放共享 env,专属 worker env 不重复定义。旧固定 REQUEST_TIMEOUT_MS 已删除。
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=16
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=5000
GENARRATIVE_EDITOR_BGFILTER_BASE_URL=http://58.87.105.82/bgfilter
GENARRATIVE_EDITOR_BGFILTER_TOKEN=
# BgFilter 失败后的中间兜底:阿里云通用抠图(SegmentCommonImage)。AccessKey 留空则跳过该层,
+3 -3
View File
@@ -1,12 +1,12 @@
# 复制到 /etc/genarrative/bgfilter-worker.env;只放专用进程独占参数和可选日志覆盖。
# provider、OSS、内部 Token 文件和请求 timeout 统一来自先加载的 api-server.env,禁止在此重复定义。
# provider、OSS、内部 Token 文件、N(CONCURRENCY)与单图估时统一来自先加载的 api-server.env,禁止在此重复定义。
# systemd unit 会强制设置 GENARRATIVE_PROCESS_ROLE=bgfilter-worker。
GENARRATIVE_ENV=production
GENARRATIVE_BGFILTER_WORKER_HOST=127.0.0.1
GENARRATIVE_BGFILTER_WORKER_PORT=8083
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=4
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=128
# Q:admission 保险丝,只防连接风暴;正常业务不应触达,显式配置时必须 >= N。
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=2048
# flat 熔断只由本进程维护;complex 不读写熔断状态。
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD=3
GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS=300
@@ -15,7 +15,7 @@ ExecStart=/usr/bin/env GENARRATIVE_PROCESS_ROLE=bgfilter-worker OTEL_SERVICE_NAM
Restart=always
RestartSec=5
KillSignal=SIGINT
# 内部 requestBudgetMs 协议上限为 600s;额外窗口用于响应发送和进程收口。
# shutdown 会立即拒绝仍在排队的请求,只排空已取得 provider permit 的调用;默认 N=16、est=5000ms 时 callBudgetMs=321s,额外窗口用于响应发送和进程收口。
TimeoutStopSec=900
LimitNOFILE=65535
TasksMax=2048
@@ -19,12 +19,12 @@
## 2026-07-21 BgFilter 首版采用单实例同步内部 HTTP 与父流程原地等待
- 背景:角色动画在单个 `external_generation_job` 内通过 `buffer_unordered(frame_count)` 可并发发射最多 `48` 次 BgFilter 请求;限制父 worker 并发不能限制单个父 job 内的实际 BgFilter 并发。父 job checkpoint / continuation 和 SpacetimeDB 持久子任务都会扩大父状态机、attempt、计费、恢复和清理改动,而当前 BgFilter 成功结果本来就是 HTTP 图片二进制。
- 决策:父 future 保持原调用栈、lease 和 attempt,等待期间继续占用通用 worker 槽并由现有 heartbeat 续租;所有调用统一同步请求唯一 `bgfilter-worker` 的内部 loopback HTTP。输入只传 OSS object key、参数和剩余预算,成功直接返回经过校验的图片二进制。子 worker 使用有界 admission `Q` 和进程内 `Semaphore(N)`,负责最多两次顺序 provider attempt、flat 进程级熔断和失败审计;父流程继续负责 flat 降级、complex 失败、Alpha / 尺寸恢复、动画 finalizer、最终 OSS、业务写回、计费和父终态。
- 超时边界:父 job 总预算仍为普通 `900s` / 长任务 `1800s`,并保留现有 `60s` 终态写回窗口;`180s` 仅是子 worker 单次真实 provider attempt 的默认上限,父侧据此派生 `2 × attempt timeout + 1s response window` 的逻辑调用上限,再按父绝对 deadline 和 flat fallback reserve 截短。内部 RPC 总预算包含 admission 后的 semaphore 等待、最多两次 attempt、结果校验和二进制返回。动画删除旧的 `2000ms × frame_count` 单次 timeout 增量,父侧不得在内部 timeout / 断连后重试整次 RPC。
- 决策:父 future 保持原调用栈、lease 和 attempt,等待期间继续占用通用 worker 槽并由现有 heartbeat 续租;所有调用统一同步请求唯一 `bgfilter-worker` 的内部 loopback HTTP。输入只传 OSS object key、参数、`maxQueueWaitMs / callBudgetMs` 和有界审计关联,成功直接返回经过校验的图片二进制。子 worker 使用有界 admission `Q` 和进程内 `Semaphore(N)`,负责最多两次顺序 provider attempt、flat 进程级熔断和失败审计;父流程继续负责 flat 降级、complex 失败、Alpha / 尺寸恢复、动画 finalizer、最终 OSS、业务写回、计费和父终态。
- 超时边界:父 job 总预算仍为普通 `900s` / 长任务 `1800s`,并保留现有 `60s` 终态写回窗口。内部协议拆成互不挪用的 `maxQueueWaitMs` 与 `callBudgetMs`:前者由父剩余绝对预算扣除调用预算和父侧预留后派生,只限制等待 provider permit;flat 的 `39s` 父侧预留由 `37s` fallback(阿里云 `30s` + 本地 `7s`)与 `2s` 传输窗组成,complex 只留 `2s` 传输窗。后者从取得 permit 后起算,覆盖签名、最多两次 attempt、结果校验和响应构造。provider attempt 上限按 `N × est × 2` 派生,调用预算按 `2 × attempt + 1s` 派生;额外 `1s` 吸收 attempt 间开销,只要剩余时间仍能容纳完整 attempt 就不得先扣响应预留。父内部 client timeout 精确取 `maxQueueWaitMs + callBudgetMs + 2s`,不在发送阶段重新裁剪两笔相对预算。冻结 `N=16`、`est=5000ms` 时 attempt / callBudget 分别为 `160s / 321s`。动画删除旧的按帧数 timeout 增量,父侧不得在内部 timeout / 断连后重试整次 RPC。
- 故障边界:首版不新增 `bgfilter_task_group`、`bgfilter_request_task`、raw OSS、checkpoint、continuation、数据库 capacity slot、共享熔断或 QPS token bucket。父或子进程崩溃、RPC 丢失时不查询、不恢复结果;父 job 沿用现有 lease / `max_attempts=1` 失败退款语义。动画首版保持所有已提交帧 collect / drain,不增加跨帧取消组。
- 部署边界:专用进程首版仍复用完整 `AppState`,因此 systemd unit 先加载共享 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖 worker 独占参数;共享 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 必须在父子进程保持同一有效值,flat 熔断 threshold / cooldown 只归子 worker。发布切换前校验父子使用同一个非空、非符号链接、`root:genarrative 0440` 的内部 Token 文件;拒绝 `external-generation-worker.env` 覆盖出不同的内部 URL、Token 文件、connect timeout、provider timeout、OSS bucket 或 endpoint(同 bucket 的独立 AK 允许),并要求父 base URL、子 `HOST / PORT` 与 readiness URL 指向同一 loopback endpoint。worker unit 使用 `TimeoutStopSec=900` 覆盖最大 `600s` 请求预算的优雅排空,运行期巡检同时检查唯一 worker unit active 与 loopback readiness。本地 `npm run dev` 同样启动独立子进程并解析第五个 dev 端口,`ProcessRole::All` 不内嵌 listener。
- 部署边界:专用进程首版仍复用完整 `AppState`,因此 systemd unit 先加载共享 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖 worker 独占参数;父子共同依赖的 `N=16` 与 `est=5000ms` 必须来自共享基础环境,worker 专属环境只管理 flat 熔断参数和默认 `Q=2048` 保险丝。发布切换前校验父子使用同一个非空、非符号链接、`root:genarrative 0440` 的内部 Token 文件,并拒绝父子内部 URL、Token、连接参数、`N / est`、OSS bucket 或 endpoint 漂移(同 bucket 的独立 AK 允许)。worker 停机时立即拒绝仍在排队的请求,只排空已取得 provider permit 的调用;unit 使用 `TimeoutStopSec=900` 覆盖默认 `321s` 调用预算及响应收口。运行期巡检同时检查唯一 worker unit active 与 loopback readiness;本地 `npm run dev` 同样启动独立子进程并解析第五个 dev 端口,`ProcessRole::All` 不内嵌 listener。
- 影响范围:已实施范围包括 `api-server` 内部 HTTP client、专用 `bgfilter-worker` listener / process role、并发与超时配置、部署和运维观测;未修改 SpacetimeDB schema、父 job schema、用户任务 DTO、任务列表或收费归属,当前待生产压测后启用。
- 验证方式:`48` 帧并发进入父 future 时,健康唯一子 worker 进程持有的 BgFilter HTTP future 峰值不得超过生产显式配置的 `N`,且 `queued + running` 不超过 `Q`;覆盖 flat / complex 降级矩阵、内部 deadline、断连后已启动请求排空、动画全帧 drain、External v1 / inline 旁路扫描、二进制大小 / MIME / 尺寸门禁、单实例部署、DDD、编码和 diff 门禁。
- 验证方式:`48` 帧并发进入父 future 时,健康唯一子 worker 进程持有的 BgFilter HTTP future 峰值不得超过生产显式配置的 `N`,且 `queued + running + egress` 不超过 `Q`(admission permit 持有到 response body 发送完成或 drop);覆盖 flat / complex 降级矩阵、内部 deadline、断连后已启动请求排空、动画全帧 drain、External v1 / inline 旁路扫描、二进制大小 / MIME / 尺寸门禁、单实例部署、DDD、编码和 diff 门禁。
- 关联文档:`docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
---
@@ -142,7 +142,7 @@
## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展
> 后续更正(2026-07-21):本条按帧数增加 `2000ms × frame_count`、形成 `244000 / 260000 / 276000ms` 单 attempt timeout 的决策,已被 2026-07-21「BgFilter 首版采用单实例同步内部 HTTP 与父流程原地等待」取代。当前角色动作的每次真实 provider attempt 与其它 BgFilter 路径一样使用默认 `180000ms` 上限,不再按帧数扩展;排队、等待 `N`、最多两次顺序 attempt、校验与响应统一受父流程派生的内部 RPC 剩余预算约束。下文保留作历史记录。
> 后续更正(2026-07-21):本条按帧数增加 timeout 的决策已被 2026-07-21「BgFilter 首版采用单实例同步内部 HTTP 与父流程原地等待」的公式化双预算取代。当前每帧分别携带 `maxQueueWaitMs` 与 `callBudgetMs`:排队预算只约束等待 provider permit,取得 permit 后才启动调用预算;provider attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 运行时派生,冻结 `N=16`、`est=5000ms` 时为 `160s / 321s`,不再按 `32 / 40 / 48` 帧扩展。下文保留作历史记录。
- 背景:角色动作全部序列帧会并发进入 BgFilter,而服务端可能在自身进程内排队;固定 `180000ms` 会把排队时间和单帧推理共用同一预算,靠后的请求可能在服务仍正常处理时被 api-server 提前取消。
- 决策:保留 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 作为统一基准值。只有角色动作逐帧 BgFilter 在共享 Client 的 RequestBuilder 上把每一次 HTTP attempt 覆盖为“基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧为 `244000 / 260000 / 276000ms`;角色形象单图、图标、UI 和手动去背景不增加帧预算。该 timeout 覆盖请求发起到响应体读取完成;首次失败后的重试重新获得同样的 request deadline,整批并发策略、失败排空语义和 worker long-job 总预算不变。
@@ -366,7 +366,7 @@
## 2026-07-03 图片画布生成纯色背景资产接入 BgFilter
> 后续更正:本条关于 multipart `file`、手动去背景独立 BiRefNet 配置以及 BgFilter 失败后直接本地兜底的描述,已分别由 2026-07-14、2026-07-15 OSS 签名 URL 决策和 2026-07-17 内存生命周期决策取代。下文保留作历史记录。
> 后续更正:本条关于 multipart `file`、手动去背景独立 BiRefNet 配置以及 BgFilter 失败后直接本地兜底的描述,已分别由 2026-07-14、2026-07-15 OSS 签名 URL 决策和 2026-07-17 内存生命周期决策取代;其中固定 provider timeout 配置也已被 2026-07-21 的公式化双预算取代,当前请求分别携带 `maxQueueWaitMs / callBudgetMs`,attempt 按 `N × est × 2` 派生,冻结 `N=16 / est=5000ms` 时为 `160s`,调用预算为 `321s`。下文保留作历史记录。
- 背景:独立 BgFilter 服务已部署在 image host,并提供 `POST /bgfilter/remove-background`,支持显式 `screen_color` 和 `seg_model`。手动去背景已有独立 BiRefNet BFF,不能把两个服务的配置或语义混在一起。
- 决策:角色形象生成、图标 spritesheet 生成和 UI 设计图素材提取在保存带纯色背景源图后,统一调用 BgFilter 生成透明 PNG;请求 multipart 字段为 `file`、`screen_color=<screenColor>` 和内部固定的 `seg_model=birefnet`。`segModel` 虽是后端可识别的内部兼容字段(另保留 `anime-seg`),但不向用户或外部 OpenAPI 暴露:当前 BgFilter 的内存与并发容量不适合由调用方自由切换模型。BgFilter 使用独立配置 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN`、`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时 `180000ms`,token 未配置时复用 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN`。手动 `POST /api/editor/images/background-removals` 继续使用独立 BiRefNet 配置 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_BASE_URL`,不受 BgFilter 影响。BgFilter 参数里的 `seg_model=birefnet` 只表示 BgFilter 内部分割后端,不等于手动去背景的独立 BiRefNet 服务。若 BgFilter 失败,api-server 对这些标准纯色背景生成图使用本地 `editor_green_screen` 兜底;连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)后,`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内直接本地兜底。角色动作背景色和抠帧口径已由 2026-07-09 决策取代:角色动作同样使用多色自动决策,抽帧后优先阿里云通用抠图,失败再按选定背景色本地兜底。更正(截至 2026-07-10 实现):BgFilter 失败与熔断期本条描述的「直接本地兜底」已过时——角色形象/图标/UI 三条静态生图链路同样先走阿里云通用抠图,仅阿里云也失败才本地 `editor_green_screen` 兜底。
File diff suppressed because one or more lines are too long
@@ -13,12 +13,12 @@
| 调度单位 | 一次逻辑 BgFilter 调用;角色动画为单帧 |
| 父流程 | 保持原 future、调用栈、lease 和 `attempt`,同步等待内部 HTTP |
| 通用 worker 槽 | 等待期间继续占用;父 heartbeat 继续运行 |
| 请求输入(父 → 子) | 只传私有 OSS `objectKey`、BgFilter 参数、剩余预算和有界审计关联;不重复传源图字节,也不传签名 URL |
| 请求输入(父 → 子) | 只传私有 OSS `objectKey`、BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联;不重复传源图字节,也不传签名 URL |
| 成功输出(子 → 父) | 内部 HTTP body 直接传回 BgFilter 结果图片的原始字节;不使用 Base64、不返回结果 object key、不先写 raw OSS |
| BgFilter worker | 首版只运行一个内部 HTTP worker 实例 |
| 并发 | 进程内 `Semaphore(N)`,并增加有界 admission 上限 `Q` |
| 重试 | 子 worker 对一次逻辑调用最多做两次顺序 provider attempt;父侧不重试整次内部 RPC |
| 超时 | 父侧管理 job / request 总预算和内部 RPC 总预算;子 worker 管理排队及单次 provider `180s` 上限 |
| 超时 | 双预算:父侧派生排队预算 `maxQueueWaitMs` 与调用预算 `callBudgetMs`;attempt 上限由 `N × est × 2` 公式运行时派生(est 默认 `5s`),排队不侵蚀调用时间 |
| flat 熔断 | 迁到唯一子 worker 的进程内状态;连续失败达到阈值后暂时跳过 BgFilter,complex 完全不参与 |
| 业务语义 | 父流程继续负责 Alpha / 尺寸恢复、flat fallback、最终 OSS、画布写回、计费和父终态 |
| 动画失败 | 首版保持当前“所有已提交帧都等待并排空”语义,不新增跨帧取消组 |
@@ -78,7 +78,7 @@ flowchart LR
P --> O
P --> C
C -->|"objectKey + 参数 + requestBudgetMs"| W
C -->|"objectKey + 参数 + maxQueueWaitMs + callBudgetMs"| W
W --> B
B --> W
W -->|"图片二进制或类型化 JSON 错误"| C
@@ -96,7 +96,7 @@ flowchart LR
这里的“同步等待”是控制流上的 request / response `await`:不会阻塞 OS 执行线程或整个父进程,但父 job future 仍留在通用 worker 的并发集合中,占用一个父 worker 槽,并由现有 heartbeat 继续续租。
首版进程角色仍复用现有完整 `AppState` 构造路径,以获得 OSS、BgFilter provider、SpacetimeDB 审计、HTTP client 和可观测性依赖;进程角色只阻止它挂载公共路由、claim 外部生成 job 或启动其它后台循环,并不等于它只需要 `N / Q` 几个环境变量。因此生产 unit 必须先加载 `/etc/genarrative/api-server.env`,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖监听地址、`N / Q` 和 worker 独占参数。后续若拆出轻量专用 state,可再缩小共享配置依赖,首版不能假设该拆分已经存在。
首版进程角色仍复用现有完整 `AppState` 构造路径,以获得 OSS、BgFilter provider、SpacetimeDB 审计、HTTP client 和可观测性依赖;进程角色只阻止它挂载公共路由、claim 外部生成 job 或启动其它后台循环,并不等于它只需要几个调度环境变量。因此生产 unit 必须先加载 `/etc/genarrative/api-server.env` 中父子共享的 `N / est` 与基础配置,再加载 `/etc/genarrative/bgfilter-worker.env` 覆盖监听地址、`Q`、flat 熔断和 worker 独占参数。后续若拆出轻量专用 state,可再缩小共享配置依赖,首版不能假设该拆分已经存在。
### 3.1 图片数据流口径
@@ -136,7 +136,8 @@ Authorization: Bearer <internal-token>
"screenColor": "#00ff00",
"segModel": "birefnet",
"crossCheck": true,
"requestBudgetMs": 300000,
"maxQueueWaitMs": 540000,
"callBudgetMs": 321000,
"auditContext": {
"userId": "bounded-internal-id",
"profileId": null,
@@ -151,7 +152,7 @@ Authorization: Bearer <internal-token>
- 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。
- `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。
- `screenColor` 只对 flat 必填;complex 不得误接 flat 参数或熔断。
- `requestBudgetMs` 是相对预算,不是跨机器绝对时间。当前实现从内部鉴权通过并取得 `Q` admission permit 的时刻起算;`Q` 满时立即返回 `overloaded`,成功 admission 后的 JSON 解析、等待 `N` permit、provider attempt、结果校验和响应构造都消耗该预算。
- `maxQueueWaitMs` 与 `callBudgetMs` 都是相对预算,不是跨机器绝对时间。前者从 admission 起约束排队阶段(worker 还会用 §5.2 的动态估计对其取 min);后者从取得 provider permit 起计时,覆盖签名、两次 attempt、结果校验和响应构造。`callBudgetMs` 必须等于 worker 本进程按 `N / est` 公式算出的值,不一致按 `invalid_request` 拒绝,用于快速暴露父子配置漂移。
- JSON body 设置很小的固定上限;源图字节不进入该 JSON。
签名 URL 必须在取得 provider permit 后、每次 attempt 前生成,避免排队期间过期。签名 URL 只存在于子 worker 内存和发往 BgFilter 的请求中。
@@ -177,8 +178,8 @@ Content-Type: image/png
- `provider_exhausted`:两次真实 provider attempt 都失败;
- `circuit_open`:flat 熔断已打开,未发送 provider 请求;
- `deadline_exceeded`:排队、provider 或响应阶段预算耗尽,使用 `phase = queue | provider | response`;
- `overloaded`:`queued + running` 已达到 `Q`;
- `deadline_exceeded`:排队、provider 或响应阶段预算耗尽,使用 `phase = queue | provider | response`;`phase = queue` 时附带触发边界 `bound = estimate | parent`,区分动态过载探测与父上限;
- `overloaded`:admission 保险丝 `Q` 触达(默认 `2048`,正常业务不应出现);
- `cancelled`:保留错误码,首版子 worker 不产生。首版没有显式取消信号通道,单纯 TCP 断连后 handler future 被 drop、也无法再返回响应;该码为第二阶段 group cancellation 预留,父侧已按“不启动 fallback”实现映射;
- `invalid_request`:内部契约不合法;
- `unauthorized`:内部 Token 缺失或不匹配;
@@ -192,6 +193,7 @@ Content-Type: image/png
"error": {
"code": "deadline_exceeded",
"phase": "queue",
"bound": "parent",
"attemptsStarted": 0,
"message": "BgFilter 内部请求预算已耗尽"
}
@@ -202,47 +204,69 @@ HTTP status 只作粗粒度传输分类,父侧以稳定 `error.code` 映射业
## 5. 超时所有权
### 5.1 三层预算
### 5.1 双预算分工
新版不是“父 worker 完全不再管理 BgFilter 超时”,而是三层分工:
本版把一次内部 RPC 的时间拆成两笔互不挪用的预算,排队不再侵蚀调用时间:
1. 父 worker 继续管理父 job 总预算:普通 `900s`、长任务 `1800s`。
2. 父侧为每次内部 RPC 计算 worker `requestBudgetMs` 和略长的 parent client timeout;前者覆盖等待 semaphore、最多两次 provider attempt、结果校验和响应构造,后者再覆盖 loopback 传输与父侧读取。
3. 子 worker 管理单次真实 BgFilter attempt,默认上限仍为 `180s`。
2. 排队预算 `maxQueueWaitMs`:父侧按自己的剩余绝对预算派生,只约束“在 worker 内等待 provider permit”的阶段。
3. 调用预算 `callBudgetMs`:从取得 provider permit 时起算,覆盖签名、最多两次 provider attempt、结果校验和响应构造;排队时长不消耗它,每个真正开跑的请求都保证有完整的两次 attempt 窗口。
queue job 的总预算从父 job 开始执行时起算,不从开始申请 BgFilter 时重新计时。现有父 worker 还会把 provider deadline 设在 job deadline 前 `60s`,为最终写回和终态保留时间。同步 RPC 实现必须显式读取父侧剩余 provider budget 并传入 `requestBudgetMs`,不能像当前 BgFilter helper 一样忽略 `RequestContext` deadline。
所有派生值都在运行时由 `N` 与单图估时 `est` 两个配置计算,代码不得硬编码计算结果:
本版没有 BgFilter 子任务等待 claim 的阶段。几个起算点必须区分:父 job 在数据库中尚未被 claim 的等待不消耗 job 执行预算;父 job 开始实际执行后,生图及 BgFilter 之前的耗时都会消耗父总预算;父内部 HTTP client timeout 从开始发送请求起覆盖 loopback 传输、worker admission、等待 `N`、provider 和回包;单次 `180s` attempt timer 只在子 worker 真正开始一次 BgFilter provider HTTP 时启动。父侧不是放弃超时,而是不再直接执行 provider 单次 attempt 的计时器。
```text
est = GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS(默认 5000ms)
attempt = N × est × 2
callBudgetMs = 2 × attempt + 1s worker 响应构造窗
maxQueueWaitMs = 父剩余绝对预算 − callBudgetMs − 父侧预留
flat:39s = 37s fallback(阿里云 30s + 本地 7s)+ 2s 传输窗
complex:2s 传输窗
queue timeout = min((进入 provider 等待队列时的队长 + 5) × est × 2, maxQueueWaitMs) ← worker 侧计算
parent client timeout = maxQueueWaitMs + callBudgetMs + 2s 传输窗
```
`maxQueueWaitMs <= 0` 时父侧不得发送请求:flat 直接进入既有“阿里云 → 本地”fallback,complex 直接失败;不允许把注定超时的请求塞进队列。flat 扣除的 `39s` 父侧预留由 `37s` fallback 窗口和 `2s` 内部响应传输窗组成;complex 没有 fallback,只保留 `2s` 传输窗。
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 的计时器。
父总 deadline 到达时,现有 worker 会停止续租、释放 JoinSet 槽并把 work 交给 lease fencing 仲裁,不是立刻杀死所有内部工作。正常情况下内部 RPC 自身应在更早的 provider deadline 内结束,避免进入这条脱管路径。
### 5.2 子 worker attempt timeout
### 5.2 排队预算与自适应过载探测
父侧先按 `requestBudgetMs + 2s` 计算期望的内部 client timeout,再受父绝对 deadline 截断:
worker 通过鉴权与 `Q` admission 后,在请求完成 JSON 校验并进入 provider permit 等待队列时取得队长快照;排队 deadline 仍从 `Q` admission 时刻起算。双重上界为:
```text
parent client timeout
= min(requestBudgetMs + 2s parent transport window,
父侧当前剩余绝对预算)
queue timeout = min((进入 provider 等待队列时的队长 + 5) × est × 2, maxQueueWaitMs)
```
`requestBudgetMs` 派生时会预扣这段 transport reserve,因此正常路径仍为子 worker 保留约 `2s` 的 loopback 传输、调度抖动和父侧读取类型化错误时间;父绝对 deadline 始终是硬上限。额外 `2s` 不增加 provider 可执行时间,也不能在父预算已经不足时强行延长 client timeout。
- 动态项 `(队长 + 5) × est × 2`:按进入 provider permit 等待队列时的当前队列深度估计合理等待时间。`+5` 覆盖已在 provider 执行中、尚未释放 permit 的在途请求;`×2` 是安全系数。队列短但等待仍超出该值,说明 provider 实际速度远低于估计,趁父预算仍够时尽早返回 `deadline_exceeded (phase = queue)` 让 flat 走 fallback——动态项因此充当自适应过载探测器,替代旧 `Q` 容量拒绝的快速降级职责。tokio semaphore 按进入 provider 等待队列的顺序提供 FIFO;快照之后的后来者不影响本请求的动态上界。
- 父上限 `maxQueueWaitMs`:父侧愿意等多久由父剩余预算决定,队列再长也不能超过它。
子 worker 收到请求后使用本机单调时钟计算 RPC deadline。每次 attempt 的 timeout 为:
排队超时的错误响应必须标注触发边界(动态估计或父上限),便于区分“provider 变慢”与“父预算太紧”。
### 5.3 子 worker attempt timeout
子 worker 取得 provider permit 后,以本机单调时钟起算 `callBudgetMs` 的 RPC deadline。每次 attempt 的 timeout 为:
```text
min(180s, RPC 剩余时间 - 1s worker 响应构造窗口)
attempt = N × est × 2
attemptTimeout = attempt (RPC 剩余时间 > attempt)
= RPC 剩余时间 - 1s 响应构造窗口 (已无法容纳完整 attempt 的防御分支)
```
worker 内部 `1s` 和 parent transport `2s` 都只是固定的小型进程 / 传输窗口,不是 raw 结果持久化预留。剩余时间不足时不得开始新的 attempt;第一次失败后只有预算仍足够才开始第二次。
`callBudgetMs = 2 × attempt + 1s` 的额外 `1s` 用于吸收两次 attempt 之间的签名与调度开销;只要 RPC 剩余时间仍大于一次完整 `attempt`,第二次就必须取得完整窗口,不能先机械扣掉 `1s`,否则满窗 timeout 会被误判为预算截短并漏计熔断。已无法容纳完整 attempt 时才进入截短防御分支;被截短的 timeout 返回 `deadline_exceeded` 且不计入熔断。
flat 调用还要由父侧从可分配给 BgFilter 的预算中保留当前阿里云 request timeout(默认 `30s`)和少量本地处理余量,避免 BgFilter 排队吃完全部 provider budget 后名义上有 fallback、实际上已无时间执行。complex 没有 flat fallback,不使用该预留。
attempt 公式的依据:BgFilter 服务端高并发时单图处理约 `1-3s`、网络约 `3-5s`;`N` 个在途请求在服务端排队的最坏等待按 `N × est` 估计,`×2` 为安全系数。`N = 16`、`est = 5s` 时 attempt 为 `160s`,与旧固定 `180s` 接近,单请求行为基本持平。
`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是单次真实 provider attempt 的基础上限,默认 `180s`。父侧据此派生一次逻辑调用的上限:`2 × attempt timeout + 1s worker response window`,再按父绝对 deadline 和 flat fallback reserve 截短为 `requestBudgetMs`;parent client timeout 则取前述 `min(requestBudgetMs + 最多 2s transport window, 父绝对 deadline 剩余)`。该配置必须由父子进程使用同一个有效值:首版把它放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖成另一个值。
flat 调用还要由父侧从可分配预算中扣除 `39s`:阿里云 request timeout(默认 `30s`)与本地处理余量 `7s` 构成 `37s` fallback 窗口,另有 `2s` 内部响应传输窗,避免排队吃完全部预算后名义上有 fallback、实际上已无时间执行。
inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界;默认总上限按“两次现有 BgFilter attempt 上限 + 内部响应窗口”计算,排队时间同样包含在内。
`N` 与 `est` 必须由父子进程使用同一份有效值:父侧要用它们算 `callBudgetMs` 与 client timeout,worker 要用它们算 attempt 与队列估时。两者都放在共享 API 基础环境中作为单一来源,worker 专属环境不得悄悄覆盖;worker 侧还应校验请求携带的 `callBudgetMs` 与本进程公式值一致,配置漂移时返回 `invalid_request` 快速暴露。
### 5.3 动画旧增量
inline / External v1 当前没有显式 `RequestContext` deadline 时,内部 RPC 仍必须有界:`callBudgetMs` 同公式,`maxQueueWaitMs` 默认取与 `callBudgetMs` 等长的额度,因此默认父 client timeout 为 `2 × callBudgetMs + 2s`;后续若同步请求显式注入 deadline,再按同一 `maxQueueWaitMs` 公式从剩余预算派生。
### 5.4 动画旧增量
旧实现曾把动画单次 BgFilter timeout 设为:
@@ -250,20 +274,20 @@ inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界
180s + 2000ms × frame_count
```
该增量原本用于容纳 32 / 40 / 48 个请求直接进入 BgFilter 后的服务端排队。当前实现已把排队前移到内部 worker并删除该增量;所有真实 provider attempt 统一使用 `180s` 上限,动画尾部请求能等待多久由各自内部 RPC 剩余预算决定。
该增量原本用于容纳 32 / 40 / 48 个请求直接进入 BgFilter 后的服务端排队。当前实现已把排队前移到内部 worker 并删除该增量;所有真实 provider attempt 统一使用 `N × est × 2` 公式上限,动画尾部请求能等待多久由各自的排队预算决定。
## 6. 并发、排队与熔断
### 6.1 `N` 与 `Q`
唯一子 worker 使用两个简单上限:
唯一子 worker 使用一个真并发上限和一个保险丝:
- `Q`:内部请求 admission 上限,满足 `queued + running + egress <= Q`;超出立即返回 `overloaded`。
- `N`:BgFilter provider 并发 permit;handler 取得 permit 后才允许开始第一次 provider HTTP。
- `N`:BgFilter provider 并发 permit(生产 `16`);handler 取得 permit 后才允许开始第一次 provider HTTP。
- `Q`:admission 保险丝(默认 `2048`),只防调用方 bug 造成的连接风暴耗尽 fd / 连接资源,正常业务永远打不到;触达时立即返回 `overloaded`。部署与 Provision 会把历史模板默认 `128` 定向迁移为 `2048`,其它显式定制值保留。`Q` 不再承担容量策略职责——排队请求的真正上界是各自的 queue timeout(见 §5.2),队列因 deadline 自排水,长度上界约为到达率 × 父预算时长,而到达率被父侧扇出锁死。
同一次逻辑调用的 `N` permit 从第一次 provider attempt 前一直持有到两次尝试结束、provider body 读完、完成结果校验,并随内部成功 response body 一直持有到发送完成或 body drop。第二次 attempt 不重新排队。保守延长 permit 生命周期可以防止父侧慢读时积累多份完整结果;loopback 传输很短,首版接受这点吞吐代价。
同一次逻辑调用的 `N` permit 从第一次 provider attempt 前一直持有到两次尝试结束、provider body 读完、完成结果校验,并随内部成功 response body 一直持有到发送完成或 body drop。第二次 attempt 不重新排队。保守延长 permit 生命周期可以防止父侧慢读时积累多份完整结果;loopback 传输很短,接受这点吞吐代价。
仅使用 provider semaphore 会形成无界 waiter,因此 `Q` 不是可选优化。内部 listener 必须在解析 JSON body 前使用连接 / request concurrency limit 与 load shedding 完成 admission,设置固定 listen backlog 和小型 body limit;`Q` permit 同样跟随 response body 到发送完成或 drop。`Q` 不替代内核 listen backlog,也不宣称消除所有已 accept socket。首版接受 FIFO,不增加持久优先级队列或公平调度状态机。
内部 listener 仍在解析 JSON body 前完成鉴权与 `Q` admission,设置固定 listen backlog 和小型 body limit;`Q` 不替代内核 listen backlog。请求完成 JSON 校验后才进入 provider semaphore 等待队列,该队列采用 tokio semaphore 的 FIFO 语义;不增加持久优先级队列或公平调度状态机。
当前父 worker 可能提交的最大帧请求数并不只有 `96`:
@@ -273,15 +297,9 @@ inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界
| 一个默认 external-generation-worker,父并发 `2` | `96` |
| controller 最多 `8` 个父 worker、每个并发 `2` | `768` |
`Q` 是保护子 worker 的 overload 取舍,不要求覆盖理论最大 `768`。例如选择 `Q = 128` 可以容纳两个满帧动画并留少量余量,但更多父 worker 会收到 `overloaded`;flat 将进入 fallback,complex 将失败。生产必须明确是否接受该行为。
理论最大 `768` 远低于保险丝 `2048`,正常业务不会触发 `overloaded`;过载时的降级路径改由 §5.2 的动态排队探测承担——排队超出合理预期的 flat 请求提早进入 fallback,complex 失败。
理论最坏时间必须纳入容量评估:
```text
48 帧、每帧两次 180s:约 ceil(48 / N) × 360s
```
例如 `N = 4` 时理论最坏为 `4320s`,超过长 job 的 `1800s`。上线值不能只按理论最大超时推断,必须用真实 BgFilter P95、动画帧数和主机内存压测冻结;容量不足时尾部 flat 请求会在内部 deadline 后进入父侧 fallback。
容量评估:`N = 16`、单图真实耗时约 `5-8s` 时,`48` 帧的排队时间约为 `ceil(48 / 16) × 单图耗时`,远低于父 job 预算。上线值仍必须用真实 BgFilter P95、动画帧数和主机内存压测冻结;`est` 估计过小时动态探测会提早降级、attempt 超时会误伤慢图并触发熔断,因此 `est` 校准是压测的首要目标。
### 6.2 并发承诺边界
@@ -294,7 +312,7 @@ inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界
它不能绝对保证 BgFilter 服务端实际计算始终 `<= N`:客户端 timeout、进程退出或网络断开后,远端可能继续计算,而本地 permit 已被释放。若必须严格限制服务端计算,只能把全局 semaphore 放到 BgFilter 服务本身,或让 BgFilter 支持 request id、取消和状态查询。
两个子 worker 实例会得到 `2N`,因此首版使用固定内部监听地址和非模板化 systemd unit;同机第二实例应因固定端口 bind 失败。发布必须 `stop old -> 等待排空/退出 -> start new`,不能让新旧进程重叠。生产 `N` 或 `Q` 缺失、为 `0` 时 fail-closed。
两个子 worker 实例会得到 `2N`,因此首版使用固定内部监听地址和非模板化 systemd unit;同机第二实例应因固定端口 bind 失败。发布必须 `stop old -> 等待排空/退出 -> start new`,不能让新旧进程重叠。生产 `N` 或 `est` 缺失、为 `0` 时 fail-closed;`Q` 可缺省(默认 `2048`),显式配置时必须 `>= N`。
### 6.3 熔断
@@ -306,7 +324,7 @@ inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界
- flat 在取得 permit、即将发送第一次 provider HTTP 前重新检查熔断,避免 48 个排队请求在熔断打开前全部通过旧检查。
- 已经获准执行的逻辑调用,即使第一次失败使熔断打开,也仍允许在预算内完成自己的第二次顺序 attempt;后续请求快速返回 `circuit_open`。
- 每个真实失败 attempt 计一次失败,保持当前计数口径;flat 任一真实 attempt 成功后重置。
- 只有拿到完整配置 attempt 上限(默认 `180s`)后发生的 provider timeout,以及真实传输失败、非 2xx 和无效 / 超限图片计入。因 `requestBudgetMs` 剩余不足而被截短的 timeout 返回 `deadline_exceeded`,不更新熔断;排队满、排队超时、客户端取消、鉴权和本地配置错误同样不计入。
- 只有拿到完整公式 attempt 上限(`N × est × 2`)后发生的 provider timeout,以及真实传输失败、非 2xx 和无效 / 超限图片计入。因 `callBudgetMs` 剩余不足而被截短的 timeout 返回 `deadline_exceeded`,不更新熔断;保险丝拒绝、排队超时、客户端取消、鉴权和本地配置错误同样不计入。
- 进程重启后熔断状态清零是首版接受行为。
flat 熔断的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD` 与 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS` 属于 `bgfilter-worker` 运行参数;父 API / external-generation worker 不再读取或更新熔断。生产示例必须把这两个值放进 worker 专属环境,避免运维人员在父侧修改了一个实际不生效的配置。
@@ -320,9 +338,9 @@ flat 熔断的 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD` 与 `GENA
- 父侧不得自动重试整次内部 HTTP。断连时结果未知,重试可能让一次逻辑调用从最多两次 provider attempt 扩大为四次,并可能突破瞬时并发预期。
- 尚在等待 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,因此不能承诺所有断连都阻止二试,`requestBudgetMs` deadline 是最终可靠的停止条件。
- deadline 已被子 worker 观察到后,不再开始第二次 attempt。首版没有显式 cancellation signal 通道;单纯 TCP 断连只能 best-effort 阻止二试(handler future 被 drop 后自然不再开始新 attempt),Axum / Hyper 不保证立刻通知 handler,因此不能承诺所有断连都阻止二试,排队阶段的 queue timeout 与 permit 后的 `callBudgetMs` deadline 是最终可靠的停止条件。
实现时,已启动 provider attempt 应由持有 permit 的独立 task 管理;request handler 可观察到的 Drop / cancellation signal 只影响“是否继续重试和是否返回结果”,不直接丢弃已经开始的 provider future。`requestBudgetMs` deadline 是最终可靠的停止条件。
实现时,已启动 provider attempt 应由持有 permit 的独立 task 管理;request handler 可观察到的 Drop / cancellation signal 只影响“是否继续重试和是否返回结果”,不直接丢弃已经开始的 provider future。`callBudgetMs` deadline 是最终可靠的停止条件。
### 7.2 进程崩溃
@@ -377,7 +395,7 @@ BgFilter 成功二进制不是一份新的业务资产:
- MIME、魔数和实际解码结果必须一致;
- 空 body、截断 body 和超限图片按 `invalid_result` 处理。
二进制跨进程传输期间,子 worker 和父 worker 可能同时持有同一张图片。子侧 provider permit、成功 body guard 和图片校验槽均与 `N` 对齐;guard 持有到内部响应发送完成或 body drop,完整成功 body 不会积累到 `Q` 级。父侧另有固定 `P = 4` 个成功图片读取 / 解码槽,必须在开始读取 `2xx` body 前取得,并覆盖有界 body 读取与 `spawn_blocking` 校验。极端完整 body 内存按 `(N + P) × 32 MiB` 再加父子解码缓冲、provider 读取缓冲和运行时开销评估;生产 `N / Q` 必须结合主机内存压测,而不是只看 BgFilter 吞吐。
二进制跨进程传输期间,子 worker 和父 worker 可能同时持有同一张图片。子侧 provider permit、成功 body guard 和图片校验槽均与 `N` 对齐;guard 持有到内部响应发送完成或 body drop,完整成功 body 不会积累到排队规模。父侧另有固定 `P = 8` 个成功图片读取 / 解码槽,必须在开始读取 `2xx` body 前取得,并覆盖有界 body 读取与 `spawn_blocking` 校验;`P` 低于 `N` 时会在出口串行化压低吞吐,`N = 16` 配 `P = 8` 是内存与吞吐的折中。极端完整 body 内存按 `(N + P) × 32 MiB` 评估——`N = 16`、`P = 8` 时约 `768 MiB`——再加父子解码缓冲、provider 读取缓冲和运行时开销;生产 `N` 必须结合主机内存压测,而不是只看 BgFilter 吞吐。
图片解码运行在不可强制取消的 blocking task 中。父子两侧等待校验结果都必须受各自 deadline 约束;deadline 到达后请求可按类型化超时收口。子 worker 已启动但尚未结束的校验 task 继续持有图片字节和校验槽,并由 shutdown tracker 等待真实结束;provider `N` 只覆盖真实 provider 调用及内部响应发送,不因后台 CPU 校验延长而虚假占用。父侧超时后的 blocking task 继续持有父侧校验槽直到真实结束,防止后续大图无界叠加,但它没有外部副作用,不纳入子 worker 的 shutdown tracker。`TimeoutStopSec=900` 覆盖的是子 worker 的排空边界。
@@ -385,7 +403,8 @@ BgFilter 成功二进制不是一份新的业务资产:
最小指标:
- `bgfilter_internal_waiting_requests`
- `bgfilter_internal_waiting_requests`(即 admission 后等待 `N` permit 的队长)
- `bgfilter_internal_queue_timeout_total{bound}`(排队超时按触发边界 `estimate | parent` 分维度)
- `bgfilter_internal_in_flight`
- `bgfilter_internal_request_seconds{mode,outcome}`
- `bgfilter_provider_http_seconds{mode,attempt,outcome}`
@@ -404,9 +423,9 @@ BgFilter 成功二进制不是一份新的业务资产:
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 进程级熔断。
4. 增加父侧共享内部 HTTP client。该 client 不自动重试;对 `2xx` 读取并返回受限图片字节,对非 `2xx` 只解析有界类型化 JSON 错误;把父剩余预算显式转换为较短的 `requestBudgetMs` 和略长的 client timeout,并保证两者都早于父绝对 deadline。
4. 增加父侧共享内部 HTTP client。该 client 不自动重试;对 `2xx` 读取并返回受限图片字节,对非 `2xx` 只解析有界类型化 JSON 错误;把父剩余预算显式转换为 `maxQueueWaitMs` 与公式 `callBudgetMs`,client timeout 固定取两者之和加 `2s`。父绝对预算只在派生 `maxQueueWaitMs` 时扣除 callBudget 与父侧预留,不在发送阶段重新裁剪或挪用两笔相对预算。
5. 用内部 client 替换两个集中调用边界:
- flat:`remove_editor_generated_screen_background_with_bgfilter_with_request_timeout`;
- flat:`remove_editor_generated_screen_background_with_bgfilter`;
- complex:`request_editor_background_removal_image_with_bgfilter_worker`。
6. 从父侧移除 BgFilter provider retry 和 flat 熔断实现;保留 flat fallback、complex 失败、Alpha / 尺寸恢复和所有最终持久化。
7. 删除动画 `2000ms × frame_count` 单 attempt timeout 增量;保留现有所有帧 collect / drain。
@@ -426,7 +445,7 @@ BgFilter 成功二进制不是一份新的业务资产:
内部 worker 不可用时禁止自动 direct fallback。flat 仍可走业务已有阿里云 / 本地 fallback;complex 明确失败。需要整体回滚时回滚父、子进程版本和配置,不在运行中混用两种 BgFilter 调度方式。
`bgfilter-worker` 收到停止信号后先停止接收新请求,再等待已 admission 的 handler 和已启动 provider attempt 排空。内部协议允许的 `requestBudgetMs` 最大为 `600s`,因此 systemd unit 固定使用 `TimeoutStopSec=900`;部署脚本的同步 `systemctl stop` 必须允许该窗口完成,不能沿用 systemd 常见的约 `90s` 默认值强杀在途调用。
`bgfilter-worker` 收到停止信号后先停止接收新请求,并让仍在排队等待 `N` permit 的请求立即以类型化错误收口(父侧 flat 走 fallback),只等待已取得 permit 的调用和已启动 provider attempt 排空。排空上界由 `callBudgetMs`(`N = 16`、`est = 5s` 时约 `321s`)决定,因此 systemd unit 固定使用 `TimeoutStopSec=900` 仍有充分余量;部署脚本的同步 `systemctl stop` 必须允许该窗口完成,不能沿用 systemd 常见的约 `90s` 默认值强杀在途调用。排队请求不参与排空等待——它们尚未发出任何外部请求,快速失败是安全的。
### 10.3 本地开发
@@ -439,10 +458,11 @@ BgFilter 成功二进制不是一份新的业务资产:
必须覆盖:
- `48` 帧同时进入时,唯一子 worker 观测到的客户端 BgFilter HTTP future 峰值不超过 `N`。
- listener 在 body 解析前执行 concurrency limit / load shedding;`queued + running + egress` 达到 `Q` 后新请求立即返回 `overloaded`,handler 数不超过 `Q`,内核 socket backlog 按独立固定值验证。
- 排队时间计入 `requestBudgetMs`;deadline 到达后不开始新的 provider attempt。
- 不存在 BgFilter 子任务 claim 状态;成功 admission 后等待 `N` 的时间同时计入子 `requestBudgetMs` 和父 client timeout。
- parent client timeout 取 `min(requestBudgetMs + parent transport window, 父剩余绝对预算)`;预算派生正常预留响应窗口,结果校验等待也必须 deadline-aware,不能只在校验完成后事后判超时。
- 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` 运行时派生,代码不存在硬编码结果值。
- parent client timeout 精确取 `maxQueueWaitMs + callBudgetMs + 2s`,helper 保持 infallible;父绝对预算通过 `maxQueueWaitMs` 的派生公式预先约束,结果校验等待也必须 deadline-aware,不能只在校验完成后事后判超时。
- 第一次失败后预算不足时不开始第二次;父侧从不重试整次内部 RPC。
- 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。
- flat 熔断按真实失败 attempt 计数;由剩余业务预算截短的 timeout 不计入。已获准调用可完成第二次,后续排队请求快速 `circuit_open`。
@@ -453,19 +473,19 @@ BgFilter 成功二进制不是一份新的业务资产:
- 成功 body 为原始图片字节而非 Base64、JSON 或结果 object key;父侧 client 把该有界字节缓冲直接交给现有后处理,子 worker 不执行 raw OSS PUT。父、子两侧都拒绝空 body、MIME / 魔数不一致、chunked 超 `32 MiB` 和超过 `8192 × 8192` 的图片。
- `N / Q` response-body guard 在发送完成或 body drop 前不释放,慢读 / 断连时完整成功 body 不积累到 `Q` 级。
- 子侧图片校验槽与 `N` 对齐;校验等待受 child deadline 约束,超时后的 blocking 校验 task 继续持有图片字节与校验槽,并被 shutdown tracker 排空;真实 provider 调用已经结束后不继续占用 `N`。
- 父侧固定最多 `4` 个成功图片读取 / 解码槽,permit 在读取 `2xx` body 前取得并覆盖 `spawn_blocking` 校验;内部响应并发再高也不能无界累积父侧完整 body 或解码任务。
- 父侧固定最多 `8` 个成功图片读取 / 解码槽,permit 在读取 `2xx` body 前取得并覆盖 `spawn_blocking` 校验;内部响应并发再高也不能无界累积父侧完整 body 或解码任务。
- worker 重启 / RPC 丢失不查询、不恢复结果;父 job 的 heartbeat、lease、失败退款和 fencing 保持现状。
- External v1 / inline 不再直连 BgFilter;公共 router、BFF、账单和任务列表中没有内部 endpoint 或内部调用记录。
- 生产不存在两个同时运行的 `bgfilter-worker`,配置缺失或 `N / Q = 0` 时 fail-closed。
- worker unit 先加载共享 API env、再加载 worker 专属 env;父子有效 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 完全一致,flat 熔断参数只由子 worker 配置和执行。
- `external-generation-worker.env` 后加载时不得把内部 base URL、Token / Token 文件、connect timeout、provider attempt timeout、OSS bucket 或 endpoint 覆盖为与共享 API env 不同的有效值;父侧必须把源对象写到子 worker 将要签名读取的同一 OSS 位置。外部生成 worker 可使用同 bucket 下权限等价或更小的独立 AK,不要求凭据文本相同。
- 生产不存在两个同时运行的 `bgfilter-worker`,`N` 或 `est` 缺失、为 `0` 时 fail-closed;`Q` 显式配置时必须 `>= N`。
- worker unit 先加载共享 API env、再加载 worker 专属 env;父子有效 `N` 与 `est` 完全一致(两者都在共享 API env),flat 熔断参数只由子 worker 配置和执行。
- `external-generation-worker.env` 后加载时不得把内部 base URL、Token / Token 文件、connect timeout、`N`、`est`、OSS bucket 或 endpoint 覆盖为与共享 API env 不同的有效值;父侧必须把源对象写到子 worker 将要签名读取的同一 OSS 位置。外部生成 worker 可使用同 bucket 下权限等价或更小的独立 AK,不要求凭据文本相同。
- 父进程 `GENARRATIVE_BGFILTER_WORKER_BASE_URL`、子 worker `HOST / PORT` 和部署 readiness URL 必须指向同一个 `127.0.0.1:<port>` endpoint;旧非空配置不能因为“无需补默认值”而绕过一致性检查。
- `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖最大 `600s` 内部请求预算和停止收口余量。
- `genarrative-bgfilter-worker.service` 必须保持 `TimeoutStopSec=900`,覆盖 `callBudgetMs` 排空上界(约 `321s`)与停止收口余量;停止时排队请求立即类型化失败,不参与排空。
- 发布目录切换前拒绝缺失、空、符号链接或权限错误的内部 Token 文件;父、子有效 Token 文件路径必须相同。
- 生产运行期巡检同时检查 `genarrative-bgfilter-worker.service` 为 active 且 `127.0.0.1:8083/readyz` 成功,不能只依赖 systemd 自动重启。
- `npm run dev` 启动独立 BgFilter 子进程并使用解析后的第五个端口;`all` 角色不内嵌 listener,单模块入口、watch、状态文件和退出清理没有遗留进程或硬编码端口。
本地全进程调度门禁使用已构建的 `api-server` binary 和 loopback mock provider,不读取真实 OSS / BgFilter 密钥,也不访问真实外部服务。`load-smoke` 固定验证 `R = 32 / 40 / 48、N = 4、Q = 128`;`fault-smoke` 用独立 worker / mock 生命周期验证 `Q` 满快速拒绝、queue deadline、`503 → 200` 顺序重试、两次 `503` 后 provider exhausted,以及 provider 成功响应 body 中途 reset 后第二次 attempt 串行成功。默认读取 `server-rs/target/debug/api-server(.exe)`;在 WSL 或自定义 target 目录运行时,通过 `GENARRATIVE_BGFILTER_SMOKE_BINARY` 指定 binary:
本地全进程调度门禁使用已构建的 `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:
```bash
cargo build -p api-server --manifest-path server-rs/Cargo.toml
@@ -499,11 +519,12 @@ git diff --check
架构边界已固定:首版单实例、无 QPS、无数据库任务表、无 checkpoint / continuation、输出直接走内部二进制响应。
代码与部署接线已经完成;生产压测后、正式启用前需冻结两个容量值:
当前冻结值与校准要求:
- 生产 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY = N`。
- 生产 `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS = Q`。若要求一个满帧动画不因自身 admission 被拒绝,`Q` 至少为 `48`;候选 `128` 可容纳两个满帧动画并留少量余量,但明确不能覆盖 controller 理论最大 `768` 次同时提交,超出部分会按 mode fallback 或失败。最终值以可接受的 overload 行为和内存压测为准。
- 生产 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY = N = 16`。
- 生产 `GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS = est = 5000`。依据:BgFilter 服务端高并发单图处理约 `1-3s`、网络约 `3-5s`。est 是 attempt、callBudget 与队列估时三个公式的共同地基,估计过小会导致排队提早降级、attempt 误伤慢图并触发熔断;生产压测的首要目标是校准该值。
- `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS = Q` 为可选保险丝,默认 `2048`,仅防连接风暴;显式配置时必须 `>= N`。
其余首版固定边界:provider 单 attempt 默认 `180s`,内部响应最大 `32 MiB / 8192 × 8192`,内部 listener 只绑定 loopback 且必须鉴权。若要多实例、严格服务端全局并发、QPS 或动画 peer cancellation,应先升级本文,不在编码中临时扩 scope。
其余固定边界:内部响应最大 `32 MiB / 8192 × 8192`,父侧解码槽 `P = 8`,内部 listener 只绑定 loopback 且必须鉴权。旧的独立 provider attempt timeout 配置已删除,attempt 上限不再独立配置。若要多实例、严格服务端全局并发、QPS 或动画 peer cancellation,应先升级本文,不在编码中临时扩 scope。
配置归属同时冻结:`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000` 来自父子共同加载的 API 基础环境;`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`、`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`、`N` 与 `Q` 由 worker 专属环境管理。若部署脚本发现共享值被 worker 环境重复定义且不同,必须在启动前失败。
配置归属同时冻结:`N` 与 `est` 来自父子共同加载的 API 基础环境(父侧算 callBudget / client timeout、worker 算 attempt / 队列估时都依赖它们);`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`、`GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS` 与可选的 `Q` 由 worker 专属环境管理。若部署脚本发现共享值被 worker 环境重复定义且不同,必须在启动前失败。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -61,7 +61,7 @@
仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用后端自动决策出的抠图背景色。纯色背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现与背景色相同或相近的描边、底板、投影或反光。
```
- 父流程收到 spritesheet 后先把带解析后纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲,不克隆保留),写入项目资源和账号素材库;随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,请求中的源图只以 object key 传递,并附带 BgFilter 参数、剩余预算和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在内部 RPC 总预算内最多执行两次顺序 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,也不重试整次内部 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 / 资源 / 画布持久化仍全部由父流程负责。
- 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 参数、剩余预算和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 固定传 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=off`,不包含 `file`,并在内部 RPC 总预算内最多执行两次顺序 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,也不重试整次内部 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、项目资源和账号素材库,再按 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`。
@@ -67,7 +67,7 @@
角色设定:<用户输入的角色设定>
```
- 角色图生成完成后,编辑器父流程必须先把带自动决策纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲),随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;请求中的源图只以 object key 传递,并附带 BgFilter 参数、剩余预算和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 字段包含 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`,并在内部 RPC 总预算内最多执行两次顺序 attempt;用户路径默认并只提交 `seg_model=birefnet`,`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,父流程输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回 `imageSrc: "/<objectKey>"`、`objectKey`、`assetObjectId` 及资源快照,画布同时写入透明主结果和 provider 原图,生成器 `generatedLayerId` 锚定透明主结果,provider 原图作为第二个图层放在其右侧。BgFilter、阿里云与本地键色最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果的 OSS / 资源 / 画布持久化仍全部由父流程负责;前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。
- 角色图生成完成后,编辑器父流程必须先把带自动决策纯色背景的源图 owned 上传私有 OSS(消费图片字节所有权,上传完成后释放原图缓冲),随后只持 object key,并仅向同机唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC;请求中的源图只以 object key 传递,并附带 BgFilter 参数、排队预算 `maxQueueWaitMs`、调用预算 `callBudgetMs` 和有界审计关联,父流程不签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在默认 `Q=2048` admission 保险丝和 `Semaphore(N=16)` 约束下执行这次逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 字段包含 `image_url`、`screen_color=<screenColor>`、`seg_model=<segModel>`、`background_mode=flat` 和 `cross_check=on`,不包含 `file`。排队只消耗 `maxQueueWaitMs`;取得 provider permit 后才启动 `callBudgetMs`,attempt 按 `N × est × 2`、调用预算按 `2 × attempt + 1s` 派生,冻结 `est=5000ms` 时分别为 `160s / 321s`,同一次逻辑调用最多执行两次顺序 attempt。用户路径默认并只提交 `seg_model=birefnet`,`flat` 明确表示单一纯色背景抠图模式,`birefnet` 是 BgFilter 管线内部后端。成功时,子 worker 通过内部 HTTP 二进制 body 把经过校验的图片字节直接返回父流程,不持久化中间结果;BgFilter 最终失败且父业务预算仍有效时,由父流程进入“阿里云通用抠图(按签名 URL 单独下载)→ 本地键色(再按 object key 独立下载一次原图并在产出后释放)”降级链。角色图 prompt 按 `screenColor` 写入颜色名称、hex 和 RGB。该流程不再调用 RPG / 资产工坊的角色主图专用 `character_visual_assets` 后处理,也不复用手动去背景的 `background_mode=complex` 路径。透明背景处理正常成功时,父流程输出透明背景 PNG,随后写入 OSS 私有对象并确认 `asset_object`;接口回包返回 `imageSrc: "/<objectKey>"`、`objectKey`、`assetObjectId` 及资源快照,画布同时写入透明主结果和 provider 原图,生成器 `generatedLayerId` 锚定透明主结果,provider 原图作为第二个图层放在其右侧。BgFilter、阿里云与本地键色最终均失败、但 provider 原图已经持久化时,任务以 `completed + warning` 收口,只把 provider 原图作为唯一主图放入画布,`generatedLayerId` 指向原图,不创建不存在的透明处理图;通用 `warning.code/reason` 携带完整降级原因。该收口只捕获透明背景处理本身的最终失败;phase 上报、provider 原图持久化、透明处理图持久化和 `canvasCompletion` 写回错误仍正常传播,不能被原图降级吞掉。最终透明结果的 OSS / 资源 / 画布持久化仍全部由父流程负责;前端创建图层和画板资源记录时必须保存最终回包对应的媒体引用。
- 对 `assetKind: "character"` 的角色图层执行 `重绘` 时,前端仍使用原图作为参考图,但请求 `kind` 必须传 `character`,让后端继续套用上述角色提示词限定、角色图后处理和角色资产持久化;透明背景正常成功与最终失败保留 provider 原图的收口规则和角色新生成一致。普通图片图层重绘仍保持 `kind: "quick-edit"`。
## 生成规范参考图
@@ -166,7 +166,7 @@
- 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。
- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。
- 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。
- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续父流程只持 object key,并为每帧向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,不直接签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。子 worker 在 `Q` admission 和 `Semaphore(N)` 约束下调度每帧逻辑调用,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。每个真实 provider attempt 的上限统一为 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000ms`,不再按帧数增加;`32 / 40 / 48` 帧使用相同的单 attempt 上限,排队、等待 `N`、最多两次顺序 attempt、结果校验和响应构造都由该帧内部 RPC 的总预算覆盖。成功图片由子 worker 以内部 HTTP 二进制 body 返回父流程,不在子侧落 OSS;BgFilter 最终失败且父业务预算仍有效时,由父流程进入 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。
- 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续父流程只持 object key,并为每帧向唯一 loopback `bgfilter-worker` 发起一次内部 HTTP RPC,不直接签发 BgFilter URL、不直连 provider,也不重试整次内部 RPC。每帧请求分别携带按父剩余绝对预算派生的 `maxQueueWaitMs` 和公式化 `callBudgetMs`;子 worker 在默认 `Q=2048` admission 保险丝和 `Semaphore(N=16)` 约束下排队,取得 provider permit 后才启动调用预算,每次 provider attempt 前重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。真实 provider attempt 按 `N × est × 2` 派生,调用预算按 `2 × attempt + 1s` 派生;冻结 `est=5000ms` 时分别为 `160s / 321s`,排队不侵蚀最多两次顺序 attempt 的完整窗口,`32 / 40 / 48` 帧也不再增加单帧 attempt。成功图片由子 worker 以内部 HTTP 二进制 body 返回父流程,不在子侧落 OSS;BgFilter 最终失败且父业务预算仍有效时,由父流程进入 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。
- BgFilter、阿里云或本地键色返回透明结果后,父流程继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边;最终帧 OSS 与业务写回仍由父流程完成。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。
- 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合向内部 worker 提交,允许乱序完成并最终按 `frameIndex` 排序;BgFilter provider 的实际在途请求受唯一 worker 的 `N / Q` 限制。任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。
- 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。
+28 -14
View File
@@ -22,16 +22,20 @@ const LOAD_SCENARIOS = Object.freeze(
}),
),
);
const DEFAULT_WORKER_CONCURRENCY = 4;
const DEFAULT_WORKER_MAX_REQUESTS = 128;
const DEFAULT_WORKER_CONCURRENCY = 16;
const DEFAULT_WORKER_MAX_REQUESTS = 2048;
const PROVIDER_DELAY_MS = 100;
const REQUEST_BUDGET_MS = 30_000;
// 单图估时(est):attempt = N×est×2、callBudget = 2×attempt+1s 全部由它派生,
// 请求携带的 callBudgetMs 必须与 worker 端公式一致,否则被按 invalid_request 拒绝。
// 取 2500 使 N=1 的 fault 场景 attempt = 5s,与旧固定值一致,容纳 provider gate 阻塞窗口。
const SINGLE_IMAGE_ESTIMATE_MS = 2_500;
const MAX_QUEUE_WAIT_MS = 30_000;
const REQUEST_TIMEOUT_MS = 35_000;
const WORKER_START_TIMEOUT_MS = 20_000;
const LOAD_SMOKE_TIMEOUT_MS = 60_000;
const FAULT_SCENARIO_TIMEOUT_MS = 15_000;
const OVERLOAD_RESPONSE_MAX_MS = 750;
const QUEUE_DEADLINE_BUDGET_MS = 1_200;
const QUEUE_DEADLINE_MAX_WAIT_MS = 1_200;
const MAX_CAPTURED_LOG_BYTES = 64 * 1024;
const MAX_MULTIPART_BYTES = 256 * 1024;
const FAKE_OSS_ACCESS_KEY_ID = 'bgfilter-load-smoke-access-key';
@@ -75,7 +79,7 @@ export function buildIsolatedWorkerEnv({
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS: String(maxRequests),
GENARRATIVE_BGFILTER_WORKER_PORT: String(workerPort),
GENARRATIVE_EDITOR_BGFILTER_BASE_URL: providerBaseUrl,
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: '5000',
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: String(SINGLE_IMAGE_ESTIMATE_MS),
GENARRATIVE_EDITOR_GENERATION_PRICING_OVERRIDE_PATH: path.join(
tempRoot,
'missing-pricing-override.json',
@@ -438,7 +442,8 @@ async function runOverloadFaultScenario() {
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
},
async ({ provider, signal, token, workerBaseUrl }) => {
const scenario = { mode: 'complex', name: 'fault-overload' };
// callBudgetMs 必须按本场景 worker 的实际 N 派生,否则被 invalid_request 拒绝。
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-overload' };
const admitted = Array.from({ length: 4 }, (_, index) =>
requestWorker(workerBaseUrl, token, scenario, index, {
signal,
@@ -516,7 +521,7 @@ async function runQueueDeadlineFaultScenario() {
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
},
async ({ provider, signal, token, workerBaseUrl }) => {
const scenario = { mode: 'complex', name: 'fault-queue-deadline' };
const scenario = { concurrency: 1, mode: 'complex', name: 'fault-queue-deadline' };
const firstRequest = requestWorker(
workerBaseUrl,
token,
@@ -538,7 +543,7 @@ async function runQueueDeadlineFaultScenario() {
scenario,
1,
{
requestBudgetMs: QUEUE_DEADLINE_BUDGET_MS,
maxQueueWaitMs: QUEUE_DEADLINE_MAX_WAIT_MS,
signal,
timeoutMs: 3_500,
},
@@ -603,7 +608,7 @@ async function runRetryThenSuccessFaultScenario() {
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
},
async ({ provider, signal, token, workerBaseUrl }) => {
const scenario = { mode: 'complex', name: 'fault-retry-success' };
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-retry-success' };
const response = await requestWorker(
workerBaseUrl,
token,
@@ -643,7 +648,7 @@ async function runProviderExhaustedFaultScenario() {
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
},
async ({ provider, signal, token, workerBaseUrl }) => {
const scenario = { mode: 'complex', name: 'fault-provider-exhausted' };
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-provider-exhausted' };
const response = await requestWorker(
workerBaseUrl,
token,
@@ -691,7 +696,7 @@ async function runMidBodyResetThenSuccessFaultScenario() {
timeoutMs: FAULT_SCENARIO_TIMEOUT_MS,
},
async ({ provider, signal, token, workerBaseUrl }) => {
const scenario = { mode: 'complex', name: 'fault-mid-body-reset' };
const scenario = { concurrency: 2, mode: 'complex', name: 'fault-mid-body-reset' };
const response = await requestWorker(
workerBaseUrl,
token,
@@ -948,7 +953,7 @@ function requestWorker(baseUrl, token, scenario, index, options = {}) {
scenario,
requestId,
index,
options.requestBudgetMs,
options.maxQueueWaitMs,
),
),
'utf8',
@@ -968,16 +973,25 @@ function requestWorker(baseUrl, token, scenario, index, options = {}) {
});
}
export function computeCallBudgetMs(
concurrency = DEFAULT_WORKER_CONCURRENCY,
estimateMs = SINGLE_IMAGE_ESTIMATE_MS,
) {
// 与 Rust 侧 AppConfig::bgfilter_call_budget_ms 完全一致:2 × (N × est × 2) + 1s。
return 2 * (Math.max(concurrency, 1) * Math.max(estimateMs, 1) * 2) + 1_000;
}
function buildScenarioRequest(
scenario,
requestId,
index,
requestBudgetMs = scenario.requestBudgetMs ?? REQUEST_BUDGET_MS,
maxQueueWaitMs = scenario.maxQueueWaitMs ?? MAX_QUEUE_WAIT_MS,
) {
const common = {
backgroundMode: scenario.mode,
callBudgetMs: computeCallBudgetMs(scenario.concurrency),
crossCheck: false,
requestBudgetMs,
maxQueueWaitMs,
requestId,
segModel: 'birefnet',
sourceObjectKey: `generated-character-drafts/bgfilter-load-smoke/${scenario.name}/frame-${String(index).padStart(3, '0')}.png`,
+182 -45
View File
@@ -2,6 +2,7 @@
import { spawnSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -35,6 +36,7 @@ if (failures.length > 0) {
console.log('[check:production-api-deploy] OK');
function main() {
assertDeployScriptSupportsProtectedEnvCleanup();
assertDeployCopiesPingoraDirectReleaseDependencies();
assertSuccessfulDeployCanKeepMaintenance();
assertDeployRestartsActivePingoraWhenArtifactIncluded();
@@ -45,7 +47,8 @@ function main() {
assertDeployRejectsPingoraArtifactMissingManifestEntry();
assertDeployRejectsPingoraManifestEntryMissingArtifact();
assertDeployRequiresPingoraWhenRequested();
assertDeployRejectsInvalidSharedBgFilterRequestTimeout();
assertDeployRejectsInvalidSharedBgFilterEstimate();
assertDeployRemovesRetiredBgFilterEnvThroughSudo();
assertDeployRejectsBgFilterWorkerSharedEnvDrift();
assertDeployRejectsEmptyBgFilterWorkerSharedEnvOverride();
assertDeployRejectsExternalGenerationWorkerBgFilterEnvDrift();
@@ -53,6 +56,7 @@ function main() {
assertDeployRejectsBgFilterHealthEndpointDrift();
assertDeployRejectsNonLoopbackBgFilterListener();
assertDeployRejectsMissingBgFilterWorkerCapacity();
assertDeployMigratesOldDefaultBgFilterAdmissionLimit();
assertDeployRejectsInvalidBgFilterWorkerCapacity();
assertDeployRejectsInlineBgFilterInternalToken();
assertDeployRejectsEmptyBgFilterInternalToken();
@@ -94,6 +98,18 @@ function main() {
assertMissingPingoraCanaryAccessLogParityFails();
}
function assertDeployScriptSupportsProtectedEnvCleanup() {
const deployScript = readFileSync(
'scripts/deploy/production-api-deploy.sh',
'utf8',
);
assertIncludes(
deployScript,
'removal_result="$(sudo -n python3 -c "${python_script}" "${file_path}" "${key}")"',
'退役 BgFilter env 键清理必须保留 sudo 读写分支。',
);
}
function assertSuccessfulDeployCanKeepMaintenance() {
const fixture = prepareFixture('keep-maintenance-after-success');
const result = runDeploy(fixture, { keepMaintenance: true });
@@ -349,7 +365,7 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
assertIncludes(
bgfilterUnit,
'TimeoutStopSec=900',
'BgFilter unit 必须给最多 600s 的内部请求预算留足优雅排空时间。',
'BgFilter unit 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间。',
);
assertFileExists(
path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'),
@@ -380,7 +396,8 @@ function assertDeployCopiesPingoraDirectReleaseDependencies() {
'BgFilter 专属 env 必须提供 flat 熔断阈值。',
);
for (const sharedKey of [
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=',
'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=',
'GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=',
'GENARRATIVE_EDITOR_BGFILTER_BASE_URL=',
'ALIYUN_OSS_ACCESS_KEY_ID=',
]) {
@@ -815,31 +832,76 @@ function assertReadinessFailureKeepsMaintenanceAfterCurrentSwitch() {
}
}
function assertDeployRejectsInvalidSharedBgFilterRequestTimeout() {
const fixture = prepareFixture('invalid-shared-bgfilter-request-timeout');
function assertDeployRejectsInvalidSharedBgFilterEstimate() {
const fixture = prepareFixture('invalid-shared-bgfilter-estimate');
writeFileSync(
fixture.apiEnvFile,
`${readFileSync(fixture.apiEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=invalid\n`,
`${readFileSync(fixture.apiEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=invalid\n`,
'utf8',
);
const result = runDeploy(fixture);
if (result.status === 0) {
failures.push('共享 BgFilter provider attempt timeout 非法时部署必须失败。');
failures.push('共享 BgFilter 单图估时非法时部署必须失败。');
}
assertIncludes(
result.stderr,
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒',
'共享 BgFilter timeout 预检必须给出明确错误。',
'GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS 必须在共享 API env 中配置为正整数毫秒',
'共享 BgFilter 单图估时预检必须给出明确错误。',
);
if (
readOptionalCommandsLog(fixture).includes(
'systemctl stop genarrative-bgfilter-worker.service',
)
) {
failures.push('共享 BgFilter timeout 预检失败时不得停止当前 worker。');
failures.push('共享 BgFilter 单图估时预检失败时不得停止当前 worker。');
}
assertMaintenanceCleared(fixture, '共享 BgFilter 单图估时预检失败');
}
function assertDeployRemovesRetiredBgFilterEnvThroughSudo() {
const fixture = prepareFixture('retired-bgfilter-env-sudo-removal');
writeFileSync(
fixture.apiEnvFile,
`${readFileSync(fixture.apiEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000\n`,
'utf8',
);
writeFileSync(
fixture.bgfilterWorkerEnvFile,
`${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=4\n`,
'utf8',
);
chmodSync(fixture.apiEnvFile, 0o000);
chmodSync(fixture.bgfilterWorkerEnvFile, 0o000);
let result;
try {
result = runDeploy(fixture, {
sudoEnvFiles: [fixture.apiEnvFile, fixture.bgfilterWorkerEnvFile],
});
} finally {
chmodSync(fixture.apiEnvFile, 0o600);
chmodSync(fixture.bgfilterWorkerEnvFile, 0o600);
}
assertStatus(result, 0, 'root-only BgFilter env 应通过 sudo 完成退役键清理。');
if (result.status !== 0) {
return;
}
if (
readFileSync(fixture.apiEnvFile, 'utf8').includes(
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=',
)
) {
failures.push('sudo 清理后共享 API env 不得保留旧固定 BgFilter timeout。');
}
if (
readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8').includes(
'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=',
)
) {
failures.push('sudo 清理后 BgFilter 专属 env 不得保留旧并发 N。');
}
assertMaintenanceCleared(fixture, '共享 BgFilter timeout 预检失败');
}
function assertDeployRejectsEmptyBgFilterInternalToken() {
@@ -915,17 +977,17 @@ function assertDeployRejectsBgFilterWorkerSharedEnvDrift() {
const fixture = prepareFixture('bgfilter-worker-shared-env-drift');
writeFileSync(
fixture.bgfilterWorkerEnvFile,
`${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=120000\n`,
`${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=120000\n`,
'utf8',
);
const result = runDeploy(fixture);
if (result.status === 0) {
failures.push('BgFilter 专属 env 覆盖不同的共享 timeout 时部署必须失败。');
failures.push('BgFilter 专属 env 覆盖不同的共享单图估时时部署必须失败。');
}
assertIncludes(
result.stderr,
'BgFilter 专属 env 中的共享配置与 API env 不一致: GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS',
'BgFilter 专属 env 中的共享配置与 API env 不一致: GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS',
'共享配置漂移预检必须给出具体变量名。',
);
if (
@@ -968,9 +1030,9 @@ function assertDeployRejectsEmptyBgFilterWorkerSharedEnvOverride() {
function assertDeployRejectsExternalGenerationWorkerBgFilterEnvDrift() {
const cases = [
[
'request-timeout',
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=120000',
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS',
'single-image-estimate',
'GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=120000',
'GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS',
],
[
'base-url',
@@ -1080,7 +1142,7 @@ function assertDeployRejectsInvalidBgFilterWorkerCapacity() {
const fixture = prepareFixture('bgfilter-worker-invalid-capacity');
writeFileSync(
fixture.bgfilterWorkerEnvFile,
`${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=8\nGENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=4\n`,
`${readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=4\n`,
'utf8',
);
const result = runDeploy(fixture);
@@ -1104,31 +1166,89 @@ function assertDeployRejectsInvalidBgFilterWorkerCapacity() {
}
function assertDeployRejectsMissingBgFilterWorkerCapacity() {
for (const key of [
'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY',
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS',
]) {
const fixture = prepareFixture(`bgfilter-worker-missing-${key.toLowerCase()}`);
const current = readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8');
const next = current
.split(/\r?\n/u)
.filter((line) => !line.startsWith(`${key}=`))
.join('\n');
writeFileSync(fixture.bgfilterWorkerEnvFile, `${next}\n`, 'utf8');
const result = runDeploy(fixture);
// N 已迁入共享 API env 且不可缺失;Q 是可选保险丝,缺失不再导致失败。
// deploy 脚本会用 ensure_env_value 自动补齐缺失的共享 N/est,因此这里注入
// 非法值(而不是删除)来验证 fail-closed。
const fixture = prepareFixture('bgfilter-worker-invalid-shared-concurrency');
writeFileSync(
fixture.apiEnvFile,
`${readFileSync(fixture.apiEnvFile, 'utf8')}GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=0\n`,
'utf8',
);
const result = runDeploy(fixture);
if (result.status === 0) {
failures.push(`BgFilter worker 缺少 ${key} 时部署必须失败。`);
}
assertIncludes(
result.stderr,
`${key} 必须是正整数`,
`BgFilter worker 缺少 ${key} 时必须给出明确错误。`,
);
assertBgFilterPreflightFailedBeforeSwitch(
fixture,
`BgFilter worker 缺少 ${key} 的预检失败`,
);
if (result.status === 0) {
failures.push('共享 GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 非法时部署必须失败。');
}
assertIncludes(
result.stderr,
'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 必须在共享 API env 中配置为正整数',
'共享 N 预检必须给出明确错误。',
);
assertBgFilterPreflightFailedBeforeSwitch(
fixture,
'共享 N 非法的预检失败',
);
const missingQ = prepareFixture('bgfilter-worker-missing-optional-q');
const current = readFileSync(missingQ.bgfilterWorkerEnvFile, 'utf8');
const next = current
.split(/\r?\n/u)
.filter((line) => !line.startsWith('GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS='))
.join('\n');
writeFileSync(missingQ.bgfilterWorkerEnvFile, `${next}\n`, 'utf8');
const missingQResult = runDeploy(missingQ);
if (missingQResult.status !== 0) {
failures.push('Q 缺失时应回退代码默认保险丝 2048,部署不得失败。');
}
}
function assertDeployMigratesOldDefaultBgFilterAdmissionLimit() {
const fixture = prepareFixture('bgfilter-worker-migrate-old-default-q');
const current = readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8');
const legacy = current.replace(
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=2048',
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=128',
);
if (legacy === current) {
failures.push('BgFilter Q 迁移 fixture 缺少当前默认 2048。');
return;
}
writeFileSync(fixture.bgfilterWorkerEnvFile, legacy, 'utf8');
const result = runDeploy(fixture);
if (result.status !== 0) {
failures.push(`历史默认 Q=128 迁移到 2048 时部署不应失败:${result.stderr}`);
return;
}
const migrated = readFileSync(fixture.bgfilterWorkerEnvFile, 'utf8');
if (!/^GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=2048$/mu.test(migrated)) {
failures.push('部署必须把历史模板默认 Q=128 定向迁移为 2048。');
}
if (/^GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=128$/mu.test(migrated)) {
failures.push('部署完成后不得继续保留历史模板默认 Q=128。');
}
const custom = prepareFixture('bgfilter-worker-preserve-custom-q');
const customCurrent = readFileSync(custom.bgfilterWorkerEnvFile, 'utf8');
const customized = customCurrent.replace(
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=2048',
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=512',
);
if (customized === customCurrent) {
failures.push('BgFilter 自定义 Q 保留 fixture 缺少当前默认 2048。');
return;
}
writeFileSync(custom.bgfilterWorkerEnvFile, customized, 'utf8');
const customResult = runDeploy(custom);
if (customResult.status !== 0) {
failures.push(`自定义 Q=512 时部署不应失败:${customResult.stderr}`);
return;
}
const preserved = readFileSync(custom.bgfilterWorkerEnvFile, 'utf8');
if (!/^GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=512$/mu.test(preserved)) {
failures.push('部署只能迁移历史默认 Q=128,必须保留其它显式自定义值。');
}
}
@@ -1824,7 +1944,8 @@ function prepareFixture(name) {
'GENARRATIVE_BGFILTER_WORKER_BASE_URL=http://127.0.0.1:18083',
`GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE=${bgfilterTokenFile}`,
'GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS=2000',
'GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS=180000',
'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=16',
'GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS=5000',
'',
].join('\n'),
'utf8',
@@ -1839,8 +1960,7 @@ function prepareFixture(name) {
[
'GENARRATIVE_BGFILTER_WORKER_HOST=127.0.0.1',
'GENARRATIVE_BGFILTER_WORKER_PORT=18083',
'GENARRATIVE_BGFILTER_WORKER_CONCURRENCY=4',
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=128',
'GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS=2048',
'',
].join('\n'),
'utf8',
@@ -2155,6 +2275,22 @@ function prepareFixture(name) {
' mkdir -p "${path}"',
' exit 0',
'fi',
'if [[ -n "${FAKE_SUDO_ENV_FILES:-}" && "${1:-}" == "python3" ]]; then',
' IFS="|" read -r -a env_files <<< "${FAKE_SUDO_ENV_FILES}"',
' modes=()',
' for env_file in "${env_files[@]}"; do',
' modes+=("$(/usr/bin/stat -c %a -- "${env_file}")")',
' /usr/bin/chmod u+rw -- "${env_file}"',
' done',
' set +e',
' "$@"',
' status=$?',
' set -e',
' for index in "${!env_files[@]}"; do',
' /usr/bin/chmod "${modes[$index]}" -- "${env_files[$index]}"',
' done',
' exit "${status}"',
'fi',
'exec "$@"',
'',
].join('\n'),
@@ -2293,6 +2429,7 @@ function runDeploy(fixture, options = {}) {
FAKE_RELEASE_ROOT: fixture.releaseRoot,
FAKE_RELEASE_VERSION: fixture.version,
FAKE_WORKER_STATE_FILE: fixture.workerStateFile,
FAKE_SUDO_ENV_FILES: (options.sudoEnvFiles ?? []).join('|'),
GENARRATIVE_SYSTEMD_UNIT_DIR: fixture.systemdUnitDir,
},
},
+1 -1
View File
@@ -292,7 +292,7 @@ function assertApiReleaseContainsPingoraDirectDependencies() {
assertIncludes(
bgfilterUnit,
'TimeoutStopSec=900',
'API release 的 BgFilter unit 必须给最多 600s 的内部请求预算留足优雅排空时间。',
'API release 的 BgFilter unit 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间。',
);
assertFileExists(
path.join(releaseDir, 'deploy/pingora/pingora-gateway.env.example'),
+7 -1
View File
@@ -1651,7 +1651,7 @@ const checks = [
file: 'deploy/systemd/genarrative-bgfilter-worker.service',
includes: 'TimeoutStopSec=900',
reason:
'BgFilter worker 必须给最多 600s 的内部请求预算留足优雅排空时间,不能沿用 systemd 默认停止窗口。',
'BgFilter worker 必须给取得 permit 后的公式化 callBudget 留足优雅排空时间,不能沿用 systemd 默认停止窗口。',
},
{
file: 'scripts/jenkins-server-provision.sh',
@@ -1685,6 +1685,12 @@ const checks = [
reason:
'Server-Provision 必须拒绝仅含空白字符的 BgFilter 内部 Token 文件。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'current_value="$(read_effective_env_value "${file}" "${key}")"',
reason:
'Server-Provision 迁移历史默认值时必须读取最后一次有效赋值,不能覆盖后写的自定义运行态值。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: "root:genarrative:440",
+83 -11
View File
@@ -265,6 +265,55 @@ ensure_env_value() {
write_env_value "${file_path}" "${key}" "${default_value}"
}
remove_env_key_if_present() {
local file_path="$1"
local key="$2"
local can_update_direct=0
local removal_result
local python_script='
import sys
import re
from pathlib import Path
file_path = Path(sys.argv[1])
key = sys.argv[2]
assignment_pattern = re.compile(rf"^(?:export\s+)?{re.escape(key)}\s*=")
lines = file_path.read_text(encoding="utf-8").splitlines()
next_lines = []
removed = False
for raw_line in lines:
if assignment_pattern.match(raw_line.strip()):
removed = True
continue
next_lines.append(raw_line)
if removed:
content = "\n".join(next_lines)
file_path.write_text(f"{content}\n" if content else "", encoding="utf-8")
print("removed")
'
if [[ ! -f "${file_path}" ]]; then
return
fi
if [[ -r "${file_path}" && -w "${file_path}" ]]; then
can_update_direct=1
fi
if [[ "$(id -u)" -eq 0 || "${can_update_direct}" -eq 1 ]]; then
removal_result="$(python3 -c "${python_script}" "${file_path}" "${key}")"
else
if ! sudo -n true >/dev/null 2>&1; then
echo "[production-api-deploy] 当前用户无权更新 ${file_path},且 sudo -n 不可用;无法移除已退役环境变量 ${key}。" >&2
exit 1
fi
removal_result="$(sudo -n python3 -c "${python_script}" "${file_path}" "${key}")"
fi
if [[ "${removal_result}" == "removed" ]]; then
echo "[production-api-deploy] 移除已退役的环境变量: ${key} <- ${file_path}"
fi
}
ensure_env_value_migrates_old_default() {
local file_path="$1"
local key="$2"
@@ -382,7 +431,11 @@ ensure_runtime_env_and_dirs() {
ensure_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE" "/etc/genarrative/secrets/bgfilter-worker.token"
ensure_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS" "2000"
ensure_runtime_bootstrap_secret_file_env "${api_env_file}"
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
# N 与单图估时是父子共同派生 attempt/callBudget 公式的输入,必须放共享 API env 单一来源;
# 旧固定 attempt timeout 已由公式取代。
ensure_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY" "16"
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS" "5000"
remove_env_key_if_present "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS"
tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")"
tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")"
@@ -440,17 +493,28 @@ ensure_bgfilter_worker_runtime_env_defaults() {
ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_HOST" "127.0.0.1"
ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_PORT" "8083"
# Q 仅是 admission 保险丝;把历史模板默认 128 定向迁到新默认 2048,
# 其它显式定制值继续保留。
ensure_env_value_migrates_old_default "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS" "128" "2048"
ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
ensure_env_value "${bgfilter_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
# N 已迁入共享 API env;worker 专属文件中的旧值会与共享值形成双写风险,直接移除。
remove_env_key_if_present "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY"
}
validate_bgfilter_shared_runtime_env() {
local api_env_file="$1"
local request_timeout_ms connect_timeout_ms
local shared_concurrency shared_estimate_ms connect_timeout_ms
request_timeout_ms="$(read_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS")"
if [[ ! "${request_timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then
echo "[production-api-deploy] GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒: ${api_env_file}" >&2
shared_concurrency="$(read_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY")"
if [[ ! "${shared_concurrency}" =~ ^[1-9][0-9]*$ ]]; then
echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 必须在共享 API env 中配置为正整数: ${api_env_file}" >&2
return 1
fi
shared_estimate_ms="$(read_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS")"
if [[ ! "${shared_estimate_ms}" =~ ^[1-9][0-9]*$ ]]; then
echo "[production-api-deploy] GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS 必须在共享 API env 中配置为正整数毫秒: ${api_env_file}" >&2
return 1
fi
@@ -462,15 +526,21 @@ validate_bgfilter_shared_runtime_env() {
}
validate_bgfilter_worker_capacity() {
local bgfilter_env_file="$1"
local api_env_file="$1"
local bgfilter_env_file="$2"
local concurrency max_requests
concurrency="$(read_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY")"
# N 已迁入共享 API env(worker unit 先加载它);Q 是可选保险丝(代码默认 2048),
# 显式配置时必须为正且不小于 N。
concurrency="$(read_env_value "${api_env_file}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY")"
max_requests="$(read_env_value "${bgfilter_env_file}" "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS")"
if [[ ! "${concurrency}" =~ ^[1-9][0-9]{0,8}$ ]]; then
echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 必须是正整数: ${bgfilter_env_file}" >&2
echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 必须在共享 API env 中配置为正整数: ${api_env_file}" >&2
return 1
fi
if [[ -z "${max_requests}" ]]; then
return 0
fi
if [[ ! "${max_requests}" =~ ^[1-9][0-9]{0,8}$ ]]; then
echo "[production-api-deploy] GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS 必须是正整数: ${bgfilter_env_file}" >&2
return 1
@@ -487,7 +557,8 @@ validate_bgfilter_worker_shared_env_alignment() {
local key shared_value dedicated_value
for key in \
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS \
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY \
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS \
GENARRATIVE_EDITOR_BGFILTER_BASE_URL \
GENARRATIVE_EDITOR_BGFILTER_TOKEN \
GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE \
@@ -518,7 +589,8 @@ validate_external_generation_worker_bgfilter_env_alignment() {
fi
for key in \
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS \
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY \
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS \
GENARRATIVE_BGFILTER_WORKER_BASE_URL \
GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE \
GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS \
@@ -1458,7 +1530,7 @@ if [[ -n "${BGFILTER_WORKER_SERVICE}" ]]; then
validate_bgfilter_loopback_endpoint_alignment "${API_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}" "${BGFILTER_WORKER_HEALTH_URL}"
fi
if [[ -n "${BGFILTER_WORKER_ENV_FILE}" ]]; then
validate_bgfilter_worker_capacity "${BGFILTER_WORKER_ENV_FILE}"
validate_bgfilter_worker_capacity "${API_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}"
validate_bgfilter_worker_shared_env_alignment "${API_ENV_FILE}" "${BGFILTER_WORKER_ENV_FILE}"
fi
migrate_legacy_editor_generation_pricing_override "${CURRENT_LINK}"
+6 -2
View File
@@ -3222,11 +3222,15 @@ function buildBgfilterWorkerProcessEnv({
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY:
String(
baseEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY ?? '',
).trim() || '4',
).trim() || '16',
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS:
String(
baseEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS ?? '',
).trim() || '5000',
GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS:
String(
baseEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS ?? '',
).trim() || '128',
).trim() || '2048',
GENARRATIVE_API_LOG: options.apiLog,
GENARRATIVE_SPACETIME_SERVER_URL: state.spacetimeServer,
GENARRATIVE_SPACETIME_DATABASE: options.database,
+3 -2
View File
@@ -342,8 +342,9 @@ describe('dev scheduler api-server env', () => {
expect(workerEnv.GENARRATIVE_BGFILTER_INTERNAL_TOKEN).toBe(internalToken);
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('4');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('128');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('16');
expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe('5000');
expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('2048');
});
test('Windows 本地 dev 自动注入已安装的 FFmpeg 路径', () => {
+45 -10
View File
@@ -549,6 +549,27 @@ ensure_env_value() {
fi
}
remove_env_key_if_present() {
local file="$1"
local key="$2"
if [[ ! -f "${file}" ]]; then
return
fi
if ! grep -Eq "^[[:space:]]*(export[[:space:]]+)?${key}=" "${file}"; then
return
fi
echo "[server-provision] 移除已退役的环境变量: ${key} <- ${file}"
if [[ "${DRY_RUN}" != "true" ]]; then
local tmp_file
tmp_file="$(mktemp)"
grep -Ev "^[[:space:]]*(export[[:space:]]+)?${key}=" "${file}" >"${tmp_file}"
cat "${tmp_file}" >"${file}"
rm -f "${tmp_file}"
fi
}
ensure_env_value_migrates_old_default() {
local file="$1"
local key="$2"
@@ -556,7 +577,9 @@ ensure_env_value_migrates_old_default() {
local new_default="$4"
local current_value
current_value="$(read_env_value "${file}" "${key}")"
# 迁移判断必须读取最后一次有效赋值;生产 env 若暂时存在重复键,后写值才是
# systemd EnvironmentFile 的实际语义,不能因前面的历史默认覆盖后面的自定义值。
current_value="$(read_effective_env_value "${file}" "${key}")"
if [[ -z "${current_value}" ]]; then
ensure_env_value "${file}" "${key}" "${new_default}"
return
@@ -625,20 +648,29 @@ ensure_api_runtime_env_defaults() {
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE" "/etc/genarrative/secrets/bgfilter-worker.token"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS" "2000"
ensure_runtime_bootstrap_secret_file_env "${API_ENV_FILE}"
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
# N 与单图估时是父子共同派生 attempt/callBudget 公式的输入,必须放共享 API env 单一来源。
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY" "16"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS" "5000"
remove_env_key_if_present "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS"
}
validate_bgfilter_shared_runtime_env() {
local request_timeout_ms connect_timeout_ms
local shared_concurrency shared_estimate_ms connect_timeout_ms
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ validate shared BgFilter provider attempt timeout in ${API_ENV_FILE}"
echo "+ validate shared BgFilter concurrency/estimate inputs in ${API_ENV_FILE}"
return
fi
request_timeout_ms="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS")"
if [[ ! "${request_timeout_ms}" =~ ^[1-9][0-9]*$ ]]; then
echo "[server-provision] GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS 必须在共享 API env 中配置为正整数毫秒: ${API_ENV_FILE}" >&2
shared_concurrency="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY")"
if [[ ! "${shared_concurrency}" =~ ^[1-9][0-9]*$ ]]; then
echo "[server-provision] GENARRATIVE_BGFILTER_WORKER_CONCURRENCY 必须在共享 API env 中配置为正整数: ${API_ENV_FILE}" >&2
exit 1
fi
shared_estimate_ms="$(read_effective_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS")"
if [[ ! "${shared_estimate_ms}" =~ ^[1-9][0-9]*$ ]]; then
echo "[server-provision] GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS 必须在共享 API env 中配置为正整数毫秒: ${API_ENV_FILE}" >&2
exit 1
fi
@@ -671,7 +703,8 @@ validate_bgfilter_env_file_alignment() {
local include_provider_credentials="$3"
local key shared_value dedicated_value
local -a shared_keys=(
GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS
GENARRATIVE_BGFILTER_WORKER_CONCURRENCY
GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS
GENARRATIVE_BGFILTER_WORKER_BASE_URL
GENARRATIVE_BGFILTER_INTERNAL_TOKEN_FILE
GENARRATIVE_BGFILTER_WORKER_CONNECT_TIMEOUT_MS
@@ -765,8 +798,10 @@ ensure_bgfilter_worker_runtime_env_defaults() {
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_HOST" "127.0.0.1"
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_PORT" "8083"
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY" "4"
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS" "128"
remove_env_key_if_present "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_CONCURRENCY"
# Q 已降级为可选 admission 保险丝(代码默认 2048);只迁移历史模板默认 128,
# 其它显式定制值继续保留。
ensure_env_value_migrates_old_default "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS" "128" "2048"
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
ensure_env_value "${BGFILTER_WORKER_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
}
File diff suppressed because it is too large Load Diff
@@ -74,7 +74,7 @@ use crate::{
apply_editor_screen_background_decision_to_generation_inputs,
build_editor_canvas_generated_layer_item, complete_editor_canvas_generation_with_items,
persist_editor_generated_media_asset,
remove_editor_generated_screen_background_with_bgfilter_with_request_timeout,
remove_editor_generated_screen_background_with_bgfilter,
resolve_editor_reference_object_key_for_owner,
},
editor_screen_background_decision::{
@@ -2326,7 +2326,6 @@ async fn extract_and_persist_editor_character_animation_frames(
use futures_util::StreamExt as _;
let frame_count = finalized_frames.len();
let bgfilter_request_timeout_ms = state.config.editor_bgfilter_request_timeout_ms;
let frame_results = futures_util::stream::iter(finalized_frames.into_iter().enumerate().map(
|(frame_index, frame)| async move {
process_and_persist_editor_character_animation_frame(
@@ -2339,7 +2338,6 @@ async fn extract_and_persist_editor_character_animation_frames(
request.frame_width,
request.frame_height,
request.screen_color,
bgfilter_request_timeout_ms,
audit,
)
.await
@@ -2403,7 +2401,6 @@ async fn process_and_persist_editor_character_animation_frame(
frame_width: u32,
frame_height: u32,
screen_color: EditorScreenBackgroundColor,
bgfilter_request_timeout_ms: u64,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<ProcessedEditorCharacterAnimationFrame, AppError> {
// 中文注释:每一帧只要求自己的绿幕源图先落 OSS,不再等待整批源图全部上传完成。
@@ -2435,13 +2432,14 @@ async fn process_and_persist_editor_character_animation_frame(
)
.await?;
let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
// 排队与调用预算由共享 helper 按 N/est 公式和父剩余预算派生,动画帧不再携带
// 任何按帧数放大的 timeout。
let removed = remove_editor_generated_screen_background_with_bgfilter(
state,
source_put.object_key.as_str(),
screen_color,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
bgfilter_request_timeout_ms,
audit,
)
.await?;
@@ -6417,17 +6415,14 @@ mod tests {
}
#[test]
fn editor_character_animation_bgfilter_timeout_uses_configured_single_request_limit() {
fn editor_character_animation_bgfilter_frames_share_budget_helper_without_per_frame_timeout() {
let source = include_str!("character_animation_assets.rs");
// 动画帧不携带任何自定 timeout:预算全部由共享 helper 按 N/est 公式派生。
assert_function_contains(
source,
"async fn extract_and_persist_editor_character_animation_frames",
"async fn process_and_persist_editor_character_animation_frame",
&[
"let bgfilter_request_timeout_ms = state.config.editor_bgfilter_request_timeout_ms;",
"process_and_persist_editor_character_animation_frame",
"bgfilter_request_timeout_ms",
],
&["process_and_persist_editor_character_animation_frame"],
);
assert!(!source.contains(concat!(
"EDITOR_CHARACTER_ANIMATION_BGFILTER_TIMEOUT_",
@@ -6437,6 +6432,7 @@ mod tests {
"editor_character_animation_bgfilter_",
"request_timeout_ms"
)));
assert!(!source.contains(concat!("bgfilter_request_", "timeout_ms =")));
}
#[test]
@@ -6552,9 +6548,7 @@ mod tests {
&[
"apply_chroma_key: false",
"prepare_for_bgfilter_input: true",
"state.config.editor_bgfilter_request_timeout_ms",
"process_and_persist_editor_character_animation_frame",
"bgfilter_request_timeout_ms",
".buffer_unordered(frame_count.max(1))",
".collect::<Vec<_>>()",
"frame_payloads.sort_by_key",
@@ -6584,9 +6578,8 @@ mod tests {
&[
"put_character_animation_frame_object",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"remove_editor_generated_screen_background_with_bgfilter",
"source_put.object_key.as_str()",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
"put_character_animation_frame_object",
"animation_frame",
@@ -6600,10 +6593,9 @@ mod tests {
"EDITOR_CHARACTER_ANIMATION_ASSET_KIND",
"EDITOR_CHARACTER_ANIMATION_PROVIDER_SOURCE_SLOT",
"green-screen-frame",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"remove_editor_generated_screen_background_with_bgfilter",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
"EDITOR_BGFILTER_CROSS_CHECK_ENABLED",
"bgfilter_request_timeout_ms",
"finalize_animation_frame_payload",
],
);
+50 -17
View File
@@ -18,7 +18,9 @@ const DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS: u64 = 900;
const DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS: u64 = 1_800;
pub(crate) const DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS: u64 = 1_000_000;
const DEFAULT_EDITOR_BGFILTER_BASE_URL: &str = "http://58.87.105.82/bgfilter";
const DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS: u64 = 180_000;
const DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS: u64 = 5_000;
const BGFILTER_ATTEMPT_SAFETY_FACTOR: u64 = 2;
const BGFILTER_WORKER_RESPONSE_WINDOW_MS: u64 = 1_000;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD: u32 = 3;
const DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS: u64 = 300;
const DEFAULT_ALIYUN_MATTING_ENDPOINT: &str = "imageseg.cn-shanghai.aliyuncs.com";
@@ -69,7 +71,7 @@ pub struct AppConfig {
pub editor_generation_pricing_override_path: PathBuf,
pub editor_bgfilter_base_url: String,
pub editor_bgfilter_token: Option<String>,
pub editor_bgfilter_request_timeout_ms: u64,
pub editor_bgfilter_single_image_estimate_ms: u64,
pub aliyun_matting_enabled: bool,
pub aliyun_matting_endpoint: String,
pub aliyun_matting_access_key_id: Option<String>,
@@ -278,8 +280,8 @@ impl Default for AppConfig {
bgfilter_worker_port: 8083,
bgfilter_worker_base_url: "http://127.0.0.1:8083".to_string(),
bgfilter_internal_token: None,
bgfilter_worker_concurrency: 4,
bgfilter_worker_max_requests: 128,
bgfilter_worker_concurrency: 16,
bgfilter_worker_max_requests: 2048,
bgfilter_worker_connect_timeout_ms: 2_000,
external_generation_mode: ExternalGenerationMode::Queue,
external_generation_worker_id: default_external_generation_worker_id(),
@@ -319,7 +321,8 @@ impl Default for AppConfig {
crate::editor_generation_config::default_editor_generation_pricing_override_path(),
editor_bgfilter_base_url: DEFAULT_EDITOR_BGFILTER_BASE_URL.to_string(),
editor_bgfilter_token: None,
editor_bgfilter_request_timeout_ms: DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
editor_bgfilter_single_image_estimate_ms:
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS,
aliyun_matting_enabled: true,
aliyun_matting_endpoint: DEFAULT_ALIYUN_MATTING_ENDPOINT.to_string(),
aliyun_matting_access_key_id: None,
@@ -474,6 +477,22 @@ impl Default for AppConfig {
}
impl AppConfig {
/// 单次 provider attempt 上限:`N × est × 2`。运行时派生,禁止硬编码计算结果;
/// worker 角色在 main 中覆盖 `bgfilter_worker_concurrency` 后自动生效。
pub fn bgfilter_provider_attempt_timeout_ms(&self) -> u64 {
(self.bgfilter_worker_concurrency.max(1) as u64)
.saturating_mul(self.editor_bgfilter_single_image_estimate_ms.max(1))
.saturating_mul(BGFILTER_ATTEMPT_SAFETY_FACTOR)
}
/// 调用预算:自取得 provider permit 起算,覆盖签名、两次顺序 attempt、
/// 结果校验与响应构造;排队时长不消耗它。
pub fn bgfilter_call_budget_ms(&self) -> u64 {
self.bgfilter_provider_attempt_timeout_ms()
.saturating_mul(2)
.saturating_add(BGFILTER_WORKER_RESPONSE_WINDOW_MS)
}
pub fn from_env() -> Self {
let mut config = Self::default();
@@ -541,10 +560,10 @@ impl AppConfig {
"GENARRATIVE_EDITOR_BGFILTER_TOKEN",
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN",
]);
if let Some(timeout_ms) =
read_first_positive_u64_env(&["GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS"])
if let Some(estimate_ms) =
read_first_positive_u64_env(&["GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS"])
{
config.editor_bgfilter_request_timeout_ms = timeout_ms;
config.editor_bgfilter_single_image_estimate_ms = estimate_ms;
}
if let Some(enabled) = read_first_bool_env(&["GENARRATIVE_ALIYUN_MATTING_ENABLED"]) {
config.aliyun_matting_enabled = enabled;
@@ -1577,7 +1596,7 @@ mod tests {
AppConfig, DEFAULT_EDITOR_BGFILTER_BASE_URL,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD,
DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS,
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS,
DEFAULT_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS,
DEFAULT_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS, ExternalGenerationMode,
@@ -1598,8 +1617,8 @@ mod tests {
assert_eq!(config.bgfilter_worker_port, 8083);
assert_eq!(config.bgfilter_worker_base_url, "http://127.0.0.1:8083");
assert!(config.bgfilter_internal_token.is_none());
assert_eq!(config.bgfilter_worker_concurrency, 4);
assert_eq!(config.bgfilter_worker_max_requests, 128);
assert_eq!(config.bgfilter_worker_concurrency, 16);
assert_eq!(config.bgfilter_worker_max_requests, 2048);
assert_eq!(config.bgfilter_worker_connect_timeout_ms, 2_000);
assert!(config.llm_model.is_empty());
assert!(config.llm_base_url.is_empty());
@@ -1610,9 +1629,11 @@ mod tests {
DEFAULT_EDITOR_BGFILTER_BASE_URL
);
assert_eq!(
config.editor_bgfilter_request_timeout_ms,
DEFAULT_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS
config.editor_bgfilter_single_image_estimate_ms,
DEFAULT_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS
);
assert_eq!(config.bgfilter_provider_attempt_timeout_ms(), 160_000);
assert_eq!(config.bgfilter_call_budget_ms(), 321_000);
assert_eq!(
config.editor_bgfilter_circuit_failure_threshold,
DEFAULT_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD
@@ -2355,7 +2376,7 @@ mod tests {
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_BASE_URL");
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN");
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS");
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
std::env::set_var(
"GENARRATIVE_EDITOR_BGFILTER_BASE_URL",
@@ -2365,7 +2386,10 @@ mod tests {
"GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN",
"shared-token",
);
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS", "240000");
std::env::set_var(
"GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS",
"8000",
);
}
let config = AppConfig::from_env();
@@ -2374,7 +2398,16 @@ mod tests {
config.editor_bgfilter_token.as_deref(),
Some("shared-token")
);
assert_eq!(config.editor_bgfilter_request_timeout_ms, 240_000);
assert_eq!(config.editor_bgfilter_single_image_estimate_ms, 8_000);
// attempt / callBudget 必须随 N 与 est 联动派生,而不是固定值。
assert_eq!(
config.bgfilter_provider_attempt_timeout_ms(),
config.bgfilter_worker_concurrency as u64 * 8_000 * 2
);
assert_eq!(
config.bgfilter_call_budget_ms(),
config.bgfilter_provider_attempt_timeout_ms() * 2 + 1_000
);
unsafe {
std::env::set_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN", "bgfilter-token");
@@ -2389,7 +2422,7 @@ mod tests {
unsafe {
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_BASE_URL");
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_TOKEN");
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS");
std::env::remove_var("GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS");
std::env::remove_var("GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN");
}
}
@@ -132,8 +132,6 @@ const EDITOR_BGFILTER_SEG_MODEL_ANIME_SEG: &str = "anime-seg";
const EDITOR_MATTING_SOURCE_URL_EXPIRE_SECONDS: u64 = 600;
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_ENABLED: bool = true;
pub(crate) const EDITOR_BGFILTER_CROSS_CHECK_DISABLED: bool = false;
const EDITOR_BGFILTER_LOGICAL_ATTEMPT_COUNT: u64 = 2;
const EDITOR_BGFILTER_WORKER_RESPONSE_WINDOW_MS: u64 = 1_000;
const EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW: Duration = Duration::from_secs(2);
const EDITOR_BGFILTER_FLAT_LOCAL_RESERVE: Duration = Duration::from_secs(7);
const EDITOR_PUBLICATION_MATERIAL_ASSET_KIND: &str = "editor_publication_material";
@@ -3377,13 +3375,6 @@ fn editor_background_removal_source_unsupported(message: &str) -> AppError {
}))
}
fn editor_bgfilter_worker_request_budget_limit_ms(single_attempt_timeout_ms: u64) -> u64 {
single_attempt_timeout_ms
.max(1)
.saturating_mul(EDITOR_BGFILTER_LOGICAL_ATTEMPT_COUNT)
.saturating_add(EDITOR_BGFILTER_WORKER_RESPONSE_WINDOW_MS)
}
fn editor_bgfilter_flat_deadline_reserve(aliyun_timeout_ms: u64) -> Duration {
Duration::from_millis(aliyun_timeout_ms)
.saturating_add(EDITOR_BGFILTER_FLAT_LOCAL_RESERVE)
@@ -3395,11 +3386,9 @@ async fn request_editor_background_removal_image_with_bgfilter_worker(
source_object_key: &str,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorBackgroundRemovalImage, AppError> {
let request_budget_limit_ms = editor_bgfilter_worker_request_budget_limit_ms(
state.config.editor_bgfilter_request_timeout_ms,
);
let request_budget_ms = crate::bgfilter_worker::request_budget_ms(
request_budget_limit_ms,
// 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,
);
@@ -3410,7 +3399,7 @@ async fn request_editor_background_removal_image_with_bgfilter_worker(
None,
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_DISABLED,
request_budget_ms,
max_queue_wait_ms,
audit,
)
.await
@@ -3440,31 +3429,11 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(
cross_check: bool,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state,
source_object_key,
screen_color,
seg_model,
cross_check,
state.config.editor_bgfilter_request_timeout_ms,
audit,
)
.await
}
pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
state: &AppState,
source_object_key: &str,
screen_color: EditorScreenBackgroundColor,
seg_model: &str,
cross_check: bool,
request_timeout_ms: u64,
audit: &crate::external_api_audit::ExternalApiAuditContext,
) -> Result<EditorScreenBackgroundRemovalOutput, AppError> {
let request_budget_limit_ms =
editor_bgfilter_worker_request_budget_limit_ms(request_timeout_ms);
let request_budget_ms = crate::bgfilter_worker::request_budget_ms(
request_budget_limit_ms,
// 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),
);
@@ -3475,7 +3444,7 @@ pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with
Some(screen_color.hex),
seg_model,
cross_check,
request_budget_ms,
max_queue_wait_ms,
audit,
)
.await
@@ -10318,30 +10287,43 @@ mod tests {
#[test]
fn editor_bgfilter_worker_budget_covers_two_attempts_and_parent_reserves() {
assert_eq!(
editor_bgfilter_worker_request_budget_limit_ms(180_000),
361_000
);
let config = AppConfig::default();
// N=16、est=5s → attempt=160s、callBudget=321s,全部由公式运行时派生。
assert_eq!(config.bgfilter_provider_attempt_timeout_ms(), 160_000);
assert_eq!(config.bgfilter_call_budget_ms(), 321_000);
assert_eq!(
editor_bgfilter_flat_deadline_reserve(30_000),
Duration::from_secs(39)
);
let complex_deadline = Instant::now() + Duration::from_secs(10);
let complex_budget = crate::bgfilter_worker::request_budget_ms(
editor_bgfilter_worker_request_budget_limit_ms(180_000),
// complex:父剩余 400s − 2s 传输窗 − 321s callBudget → 排队上限约 77s。
let complex_deadline = Instant::now() + Duration::from_secs(400);
let complex_wait = crate::bgfilter_worker::max_queue_wait_ms(
config.bgfilter_call_budget_ms(),
Some(complex_deadline),
EDITOR_BGFILTER_PARENT_TRANSPORT_WINDOW,
);
assert!((7_500..=8_000).contains(&complex_budget));
assert!((76_500..=77_000).contains(&complex_wait));
let flat_deadline = Instant::now() + Duration::from_secs(50);
let flat_budget = crate::bgfilter_worker::request_budget_ms(
editor_bgfilter_worker_request_budget_limit_ms(180_000),
// flat:再扣 39s fallback 预留 → 约 40s,排队吃不掉 fallback 的执行窗口。
let flat_deadline = Instant::now() + Duration::from_secs(400);
let flat_wait = crate::bgfilter_worker::max_queue_wait_ms(
config.bgfilter_call_budget_ms(),
Some(flat_deadline),
editor_bgfilter_flat_deadline_reserve(30_000),
);
assert!((10_500..=11_000).contains(&flat_budget));
assert!((39_500..=40_000).contains(&flat_wait));
// 父剩余放不下一次完整调用(排队 + callBudget + 预留)时必须为 0:不发请求直接降级。
let tight_deadline = Instant::now() + Duration::from_secs(100);
assert_eq!(
crate::bgfilter_worker::max_queue_wait_ms(
config.bgfilter_call_budget_ms(),
Some(tight_deadline),
editor_bgfilter_flat_deadline_reserve(30_000),
),
0
);
}
#[tokio::test]
@@ -10354,22 +10336,20 @@ mod tests {
external_call_deadline: Instant::now().checked_sub(Duration::from_millis(1)),
};
let error =
match remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
&state,
"generated-character-drafts/editor/source.png",
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
180_000,
&audit,
)
.await
{
Ok(_) => panic!("expired parent deadline should stop before flat fallback"),
Err(error) => error,
};
let error = match remove_editor_generated_screen_background_with_bgfilter(
&state,
"generated-character-drafts/editor/source.png",
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
&audit,
)
.await
{
Ok(_) => panic!("expired parent deadline should stop before flat fallback"),
Err(error) => error,
};
assert_eq!(error.status_code(), StatusCode::GATEWAY_TIMEOUT);
assert_eq!(
@@ -10390,7 +10370,6 @@ mod tests {
let state = AppState::new(AppConfig {
bgfilter_worker_base_url: base_url,
bgfilter_internal_token: Some("flat-internal-token".to_string()),
editor_bgfilter_request_timeout_ms: 20,
..AppConfig::default()
})
.expect("state should build");
@@ -10401,14 +10380,13 @@ mod tests {
external_call_deadline: None,
};
let removed = remove_editor_generated_screen_background_with_bgfilter_with_request_timeout(
let removed = remove_editor_generated_screen_background_with_bgfilter(
&state,
"generated-character-drafts/editor/source.png",
parse_editor_screen_background_color(Some("#CFEFFF"))
.expect("screen color should parse"),
EDITOR_BGFILTER_DEFAULT_SEG_MODEL,
EDITOR_BGFILTER_CROSS_CHECK_ENABLED,
2_000,
&audit,
)
.await
@@ -10438,7 +10416,9 @@ mod tests {
assert_eq!(payload["screenColor"], json!("#CFEFFF"));
assert_eq!(payload["segModel"], json!("birefnet"));
assert_eq!(payload["crossCheck"], json!(true));
assert_eq!(payload["requestBudgetMs"], json!(5_000));
// 无绝对 deadline 时排队额度取 callBudget 等长的有界默认值;两者都由 N/est 公式派生。
assert_eq!(payload["maxQueueWaitMs"], json!(321_000));
assert_eq!(payload["callBudgetMs"], json!(321_000));
assert_eq!(payload["auditContext"]["userId"], json!("user-flat"));
assert_eq!(payload["auditContext"]["requestId"], json!("request-flat"));
@@ -10456,7 +10436,6 @@ mod tests {
let state = AppState::new(AppConfig {
bgfilter_worker_base_url: base_url,
bgfilter_internal_token: Some("complex-internal-token".to_string()),
editor_bgfilter_request_timeout_ms: 5_000,
..AppConfig::default()
})
.expect("state should build");
@@ -10499,7 +10478,8 @@ mod tests {
assert!(payload["screenColor"].is_null());
assert_eq!(payload["segModel"], json!("birefnet"));
assert_eq!(payload["crossCheck"], json!(false));
assert_eq!(payload["requestBudgetMs"], json!(11_000));
assert_eq!(payload["maxQueueWaitMs"], json!(321_000));
assert_eq!(payload["callBudgetMs"], json!(321_000));
assert_eq!(
payload["auditContext"]["requestId"],
json!("request-complex")
@@ -10640,21 +10620,13 @@ mod tests {
"EDITOR_BGFILTER_CROSS_CHECK_DISABLED",
],
);
assert_function_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
&[
"state.config.editor_bgfilter_request_timeout_ms",
"remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
],
);
assert_function_contains_in_order(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"editor_bgfilter_worker_request_budget_limit_ms(request_timeout_ms)",
"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::BgfilterBackgroundMode::Flat",
@@ -10664,7 +10636,7 @@ mod tests {
);
assert_function_not_contains(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"for attempt in",
@@ -10690,7 +10662,8 @@ mod tests {
"async fn request_editor_background_removal_image_with_bgfilter_worker",
"pub(crate) struct EditorScreenBackgroundRemovalOutput",
&[
"editor_bgfilter_worker_request_budget_limit_ms",
"crate::bgfilter_worker::max_queue_wait_ms",
"state.config.bgfilter_call_budget_ms()",
"crate::bgfilter_worker::request_bgfilter_worker",
"crate::bgfilter_worker::BgfilterBackgroundMode::Complex",
"EDITOR_BGFILTER_DEFAULT_SEG_MODEL",
@@ -10780,7 +10753,7 @@ mod tests {
let source = include_str!("editor_project.rs");
assert_function_contains(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"crate::bgfilter_worker::request_bgfilter_worker",
@@ -10789,7 +10762,7 @@ mod tests {
);
assert_function_not_contains(
source,
"async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"async fn fallback_editor_screen_background_removal",
&[
"for attempt in",
@@ -11118,8 +11091,8 @@ mod tests {
assert_function_not_contains(
source,
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter(",
"pub(crate) async fn remove_editor_generated_screen_background_with_bgfilter_with_request_timeout",
&["fallback_image", "DownloadedOpenAiImage"],
"async fn fallback_editor_screen_background_removal",
&["fallback_image"],
);
assert_function_contains(
source,

Some files were not shown because too many files have changed in this diff Show More