diff --git a/docs/README.md b/docs/README.md index d8911947e..5b8222abc 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,6 +27,7 @@ ### 后端与公开数据 - [外部生成 Worker 化方案](./technical/【后端架构】外部生成Worker化方案-2026-06-03.md) +- [BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)](./technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md) - [统一公开作品 Read Model 设计](./technical/【后端架构】统一公开作品ReadModel设计-2026-05-26.md) - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [SpacetimeDB 连接池取消安全](./【后端架构】SpacetimeDB连接池租约Drop兜底与取消安全-2026-06-11.md) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bf492c40e..c5939a2ab 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,18 @@ --- +## 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` 终态写回窗口;内部 RPC 总预算包含等待 semaphore、两次 attempt、结果校验和二进制返回,子 worker 的单次 provider timeout 默认 `180s`。动画删除旧的 `2000ms × frame_count` 单次 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,不增加跨帧取消组。 +- 影响范围:后续实现涉及 `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 门禁。 +- 关联文档:`docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md`、`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + +--- + ## 2026-07-20 角色动作抠图前禁止透明 padding - 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 diff --git a/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md new file mode 100644 index 000000000..610f329df --- /dev/null +++ b/docs/technical/【后端架构】BgFilter受限资源调度方案-2026-07-21.md @@ -0,0 +1,437 @@ +# BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版) + +更新时间:`2026-07-21` + +状态:`待实施` + +> 本文替代此前讨论的“SpacetimeDB 持久子任务 + raw 结果 OSS”以及更早的“父 job checkpoint / continuation”方案。首版改为父流程在原调用栈内同步等待唯一 `bgfilter-worker` 的内部 HTTP 响应。当前代码仍由各调用方直接请求 BgFilter,本文描述目标实现。 + +## 1. 决策摘要 + +| 决策项 | 首版结论 | +| --- | --- | +| 调度单位 | 一次逻辑 BgFilter 调用;角色动画为单帧 | +| 父流程 | 保持原 future、调用栈、lease 和 `attempt`,同步等待内部 HTTP | +| 通用 worker 槽 | 等待期间继续占用;父 heartbeat 继续运行 | +| 输入 | 只传私有 OSS `objectKey`、BgFilter 参数、剩余预算和有界审计关联;不传源图字节或签名 URL | +| 成功输出 | 内部 HTTP body 直接返回图片二进制;不使用 Base64,不先写 raw OSS | +| BgFilter worker | 首版只运行一个内部 HTTP worker 实例 | +| 并发 | 进程内 `Semaphore(N)`,并增加有界 admission 上限 `Q` | +| 重试 | 子 worker 对一次逻辑调用最多做两次顺序 provider attempt;父侧不重试整次内部 RPC | +| 超时 | 父侧管理 job / request 总预算和内部 RPC 总预算;子 worker 管理排队及单次 provider `180s` 上限 | +| flat 熔断 | 迁到唯一子 worker 的进程内状态;连续失败达到阈值后暂时跳过 BgFilter,complex 完全不参与 | +| 业务语义 | 父流程继续负责 Alpha / 尺寸恢复、flat fallback、最终 OSS、画布写回、计费和父终态 | +| 动画失败 | 首版保持当前“所有已提交帧都等待并排空”语义,不新增跨帧取消组 | +| 崩溃恢复 | 不查询、不恢复 BgFilter 结果;父 job 沿用现有 lease、失败和退款语义 | +| 数据模型 | 不新增 SpacetimeDB 表,不修改 `external_generation_job` schema | + +首版明确不实现: + +- `bgfilter_task_group`、`bgfilter_request_task` 或 `bgfilter_capacity_slot`; +- 持久队列、claim、lease、reaper、subscription 或 waiter registry; +- raw 中间结果 OSS、raw TTL 或 raw 持久化预留; +- 父流程 checkpoint、`waiting_bgfilter`、continuation、requeue 或恢复不计 attempt 状态机; +- 多实例 BgFilter worker、共享持久熔断或 QPS token bucket; +- 内部 RPC 失败后偷偷回退为父进程直连 BgFilter。 + +## 2. 背景与目标 + +### 2.1 当前问题 + +角色动画当前使用 `buffer_unordered(frame_count.max(1))`。单个父 job 会让 `32 / 40 / 48` 个帧 future 同时进入 BgFilter 调用,因此限制 `external-generation-worker` 的父 job 并发不能限制实际 BgFilter 请求并发。 + +编辑器 queue job 固定 `max_attempts = 1`。普通 requeue 会改变 attempt、失败和退款语义;checkpoint / continuation 又会扩大父状态机和计费恢复改动。父流程既然可以接受继续占用 worker 槽,首版无需为 BgFilter 建第二套持久任务系统。 + +当前 BgFilter 成功结果本来就是 HTTP 图片二进制,调用方读取后再由父流程做最终处理和 OSS 持久化。因此让专用 worker 通过内部 HTTP 直接返回二进制,最接近现有数据流。 + +### 2.2 目标 + +- 所有现役 flat / complex 调用统一经过唯一内部 `bgfilter-worker`。 +- `48` 帧可同时提交内部请求,但健康唯一 worker 持有的 BgFilter HTTP future 不超过 `N`。 +- 父流程不重建,不改变父 job schema、attempt、计费和用户可见任务。 +- 保留现有 flat 两次尝试后“阿里云通用抠图 → 本地键色”、complex 两次失败直接失败的语义。 +- External v1、inline 和 queue 路径复用同一个低层 client,不能绕过限流。 +- 不把源图片、签名 URL、Token 或图片 Base64 写入数据库、JSON、审计 payload 或日志。 + +### 2.3 非目标 + +- 不释放等待中的通用 worker 槽。 +- 不保证父进程、子 worker 或内部连接崩溃后的结果恢复。 +- 不绝对限制客户端 timeout 后仍可能在 BgFilter 服务端继续的远端计算。 +- 不做跨 job 公平调度;首版有界等待采用 FIFO。 +- 不限制阿里云抠图、本地键色、OSS 或其它 provider 的并发。 +- 不改变前端 DTO、External OpenAPI、任务列表或收费归属。 + +## 3. 总体架构 + +```mermaid +flowchart LR + P["父流程:queue job / inline / External v1"] + O["已持久化的私有 OSS 源对象"] + C["内部 HTTP client"] + W["唯一 bgfilter-worker\n有界 admission(Q) + Semaphore(N)"] + B["BgFilter provider\n最多两次顺序 attempt"] + F["父流程原有后处理\nfallback / finalizer / 最终 OSS / 业务写回"] + + P --> O + P --> C + C -->|"objectKey + 参数 + requestBudgetMs"| W + W --> B + B --> W + W -->|"图片二进制或类型化 JSON 错误"| C + C --> P + P --> F +``` + +职责边界: + +- 父流程负责源对象已持久化、owner 校验、请求预算、flat fallback、Alpha / 尺寸恢复、动画 finalizer、最终 OSS / `asset_object` / 画布写回、计费和父终态。 +- `bgfilter-worker` 负责内部协议校验、OSS 签名、并发与排队上限、BgFilter 协议、两次顺序尝试、flat 熔断、provider 失败审计和结果图片校验。 +- SpacetimeDB 不参与本次内部调度;不新增表、reducer、procedure、facade 或生成 bindings。 + +`bgfilter-worker` 从实现形态看是只监听内部地址的同步 worker service,不是队列 consumer。父 worker 调另一个 worker 在这里是允许的:父进程明确选择保留调用栈和槽位,因此同步内部 HTTP 正是首版的最小交接方式。 + +## 4. 内部 HTTP 契约 + +### 4.1 请求 + +首版新增内部接口: + +```http +POST /internal/bgfilter/v1/remove-background +Content-Type: application/json +Authorization: Bearer +``` + +示例: + +```json +{ + "requestId": "bgfilter-call-uuid", + "sourceObjectKey": "generated-character-drafts/editor/source.png", + "backgroundMode": "flat", + "screenColor": "#00ff00", + "segModel": "birefnet", + "crossCheck": true, + "requestBudgetMs": 300000, + "auditContext": { + "userId": "bounded-internal-id", + "profileId": null, + "requestId": "parent-request-id" + } +} +``` + +约束: + +- 当前部署只有一个配置内私有 OSS bucket,因此请求只传 `sourceObjectKey`,子 worker 从自身 OSS 配置取 bucket 并生成短期签名 URL。 +- 如果未来确实支持多个 bucket,新增字段也必须由服务端 allowlist 校验;不能接受调用方提供任意下载 URL。 +- `backgroundMode` 只允许 `flat / complex`;`segModel` 继续沿用当前 `birefnet / anime-seg` allowlist;complex 固定使用当前参数组合。 +- `screenColor` 只对 flat 必填;complex 不得误接 flat 参数或熔断。 +- `requestBudgetMs` 是从子 worker 收到请求开始计算的相对预算,不是跨机器绝对时间。 +- JSON body 设置很小的固定上限;源图字节不进入该 JSON。 + +签名 URL 必须在取得 provider permit 后、每次 attempt 前生成,避免排队期间过期。签名 URL 只存在于子 worker 内存和发往 BgFilter 的请求中。 + +### 4.2 成功响应 + +成功直接返回经过大小、MIME 和像素尺寸校验的图片字节: + +```http +HTTP/1.1 200 OK +Content-Type: image/png + + +``` + +`image/png` 是常见响应示例。为保持当前行为,首版可以返回实际受支持的 `image/png`、`image/webp` 或 `image/jpeg`,但必须保证响应头与实际解码类型一致;不使用 JSON、Data URL 或 Base64 包装,也不为传输先落 raw OSS。 + +子 worker 必须完整读取并校验 provider body 后才向父侧返回成功,这样 provider body 中途断开时仍可在预算内执行第二次 attempt。父侧收到二进制后继续执行一次独立校验,不能只信任内部响应头。 + +### 4.3 错误响应 + +失败返回有界 JSON,稳定错误码只保留: + +- `provider_exhausted`:两次真实 provider attempt 都失败; +- `circuit_open`:flat 熔断已打开,未发送 provider 请求; +- `deadline_exceeded`:排队、provider 或响应阶段预算耗尽,使用 `phase = queue | provider | response`; +- `overloaded`:`queued + running` 已达到 `Q`; +- `cancelled`:调用方已取消且尚未开始新的 provider attempt; +- `invalid_request`:内部契约不合法; +- `unauthorized`:内部 Token 缺失或不匹配; +- `invalid_result`:provider 回图超限、MIME / 魔数不匹配或无法安全解码; +- `internal_error`:内部配置、OSS 签名或其它非 provider 故障。 + +示例: + +```json +{ + "error": { + "code": "deadline_exceeded", + "phase": "queue", + "attemptsStarted": 0, + "message": "BgFilter 内部请求预算已耗尽" + } +} +``` + +HTTP status 只作粗粒度传输分类,父侧以稳定 `error.code` 映射业务语义。错误中不得包含签名 URL、Token、图片字节、完整 provider body 或无界错误文本。 + +## 5. 超时所有权 + +### 5.1 三层预算 + +新版不是“父 worker 完全不再管理 BgFilter 超时”,而是三层分工: + +1. 父 worker 继续管理父 job 总预算:普通 `900s`、长任务 `1800s`。 +2. 父侧为每次内部 RPC 计算 worker `requestBudgetMs` 和略长的 parent client timeout;前者覆盖等待 semaphore、最多两次 provider attempt、结果校验和响应构造,后者再覆盖 loopback 传输与父侧读取。 +3. 子 worker 管理单次真实 BgFilter attempt,默认上限仍为 `180s`。 + +queue job 的总预算从父 job 开始执行时起算,不从开始申请 BgFilter 时重新计时。现有父 worker 还会把 provider deadline 设在 job deadline 前 `60s`,为最终写回和终态保留时间。同步 RPC 实现必须显式读取父侧剩余 provider budget 并传入 `requestBudgetMs`,不能像当前 BgFilter helper 一样忽略 `RequestContext` deadline。 + +父总 deadline 到达时,现有 worker 会停止续租、释放 JoinSet 槽并把 work 交给 lease fencing 仲裁,不是立刻杀死所有内部工作。正常情况下内部 RPC 自身应在更早的 provider deadline 内结束,避免进入这条脱管路径。 + +### 5.2 子 worker attempt timeout + +父侧与子 worker 的预算必须满足: + +```text +requestBudgetMs + 2s parent transport window + <= parent client timeout + <= 父侧剩余绝对预算 +``` + +parent client timeout 从父侧开始发请求时起算,`requestBudgetMs` 从子 worker 收到请求时起算,因此两者不能设成同一个值。额外 `2s` 用于 loopback 传输、调度抖动和父侧读取类型化错误,不增加 provider 可执行时间。 + +子 worker 收到请求后使用本机单调时钟计算 RPC deadline。每次 attempt 的 timeout 为: + +```text +min(180s, RPC 剩余时间 - 1s worker 响应构造窗口) +``` + +worker 内部 `1s` 和 parent transport `2s` 都只是固定的小型进程 / 传输窗口,不是 raw 结果持久化预留。剩余时间不足时不得开始新的 attempt;第一次失败后只有预算仍足够才开始第二次。 + +flat 调用还要由父侧从可分配给 BgFilter 的预算中保留当前阿里云 request timeout(默认 `30s`)和少量本地处理余量,避免 BgFilter 排队吃完全部 provider budget 后名义上有 fallback、实际上已无时间执行。complex 没有 flat fallback,不使用该预留。 + +inline / External v1 没有 queue job deadline 时,内部 RPC 仍必须有界;默认总上限按“两次现有 BgFilter attempt 上限 + 内部响应窗口”计算,排队时间同样包含在内。 + +### 5.3 动画旧增量 + +当前动画把单次 BgFilter timeout 设为: + +```text +180s + 2000ms × frame_count +``` + +该增量原本用于容纳 32 / 40 / 48 个请求直接进入 BgFilter 后的服务端排队。新版已把排队前移到内部 worker,必须删除该增量;所有真实 provider attempt 统一使用 `180s` 上限,动画尾部请求能等待多久由各自内部 RPC 剩余预算决定。 + +## 6. 并发、排队与熔断 + +### 6.1 `N` 与 `Q` + +唯一子 worker 使用两个简单上限: + +- `Q`:内部请求 admission 上限,满足 `queued + running + egress <= Q`;超出立即返回 `overloaded`。 +- `N`:BgFilter provider 并发 permit;handler 取得 permit 后才允许开始第一次 provider HTTP。 + +同一次逻辑调用的 `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,不增加持久优先级队列或公平调度状态机。 + +当前父 worker 可能提交的最大帧请求数并不只有 `96`: + +| 场景 | 潜在同时提交的动画帧调用 | +| --- | ---: | +| 一个动画父 job | `48` | +| 一个默认 external-generation-worker,父并发 `2` | `96` | +| controller 最多 `8` 个父 worker、每个并发 `2` | `768` | + +`Q` 是保护子 worker 的 overload 取舍,不要求覆盖理论最大 `768`。例如选择 `Q = 128` 可以容纳两个满帧动画并留少量余量,但更多父 worker 会收到 `overloaded`;flat 将进入 fallback,complex 将失败。生产必须明确是否接受该行为。 + +理论最坏时间必须纳入容量评估: + +```text +48 帧、每帧两次 180s:约 ceil(48 / N) × 360s +``` + +例如 `N = 4` 时理论最坏为 `4320s`,超过长 job 的 `1800s`。上线值不能只按理论最大超时推断,必须用真实 BgFilter P95、动画帧数和主机内存压测冻结;容量不足时尾部 flat 请求会在内部 deadline 后进入父侧 fallback。 + +### 6.2 并发承诺边界 + +首版只能承诺: + +```text +部署中只有一个健康 bgfilter-worker 时, +该进程当前持有的 BgFilter HTTP future <= N。 +``` + +它不能绝对保证 BgFilter 服务端实际计算始终 `<= N`:客户端 timeout、进程退出或网络断开后,远端可能继续计算,而本地 permit 已被释放。若必须严格限制服务端计算,只能把全局 semaphore 放到 BgFilter 服务本身,或让 BgFilter 支持 request id、取消和状态查询。 + +两个子 worker 实例会得到 `2N`,因此首版使用固定内部监听地址和非模板化 systemd unit;同机第二实例应因固定端口 bind 失败。发布必须 `stop old -> 等待排空/退出 -> start new`,不能让新旧进程重叠。生产 `N` 或 `Q` 缺失、为 `0` 时 fail-closed。 + +### 6.3 熔断 + +熔断是故障保护:flat 连续多次请求失败后,在 cooldown 内暂时不再请求 BgFilter,而是快速返回 `circuit_open`,由父流程进入“阿里云 → 本地”fallback,避免故障 provider 持续占满并发和超时。 + +保持当前语义: + +- 只有 flat 读取和更新熔断;complex 完全不读写。 +- flat 在取得 permit、即将发送第一次 provider HTTP 前重新检查熔断,避免 48 个排队请求在熔断打开前全部通过旧检查。 +- 已经获准执行的逻辑调用,即使第一次失败使熔断打开,也仍允许在预算内完成自己的第二次顺序 attempt;后续请求快速返回 `circuit_open`。 +- 每个真实失败 attempt 计一次失败,保持当前计数口径;成功重置。 +- 只有拿到完整配置 attempt 上限(默认 `180s`)后发生的 provider timeout,以及真实传输失败、非 2xx 和无效 / 超限图片计入。因 `requestBudgetMs` 剩余不足而被截短的 timeout 返回 `deadline_exceeded`,不更新熔断;排队满、排队超时、客户端取消、鉴权和本地配置错误同样不计入。 +- 进程重启后熔断状态清零是首版接受行为。 + +首版不增加 QPS 限制。若 provider 以后要求 QPS,需要另加 token bucket;不能把并发 semaphore 当作 QPS。 + +## 7. 断连、崩溃与动画语义 + +### 7.1 内部 RPC 断连 + +- 父侧不得自动重试整次内部 HTTP。断连时结果未知,重试可能让一次逻辑调用从最多两次 provider attempt 扩大为四次,并可能突破瞬时并发预期。 +- 尚在等待 permit 的请求到达自身 deadline 后必须取消,不再发送 provider 请求;运行时若能可靠观察客户端断连,也可提前取消,但正确性不能只依赖断连事件。 +- 已经开始的 provider attempt 必须继续读取到完成或本次 attempt timeout,并持有 permit;可观察到的 handler / client drop 只丢弃最终结果,不能让已启动请求变成无人管理的本地 future。 +- deadline 或显式 cancellation signal 已被子 worker 观察到后,不再开始第二次 attempt。单纯 TCP 断连只能 best-effort 触发 cancellation;Axum / Hyper 不保证立刻通知 handler,因此不能承诺所有断连都阻止二试。 + +实现时,已启动 provider attempt 应由持有 permit 的独立 task 管理;request handler 可观察到的 Drop / cancellation signal 只影响“是否继续重试和是否返回结果”,不直接丢弃已经开始的 provider future。`requestBudgetMs` deadline 是最终可靠的停止条件。 + +### 7.2 进程崩溃 + +- 父进程崩溃:内部连接最终断开,父 job 按现有 heartbeat、lease、`max_attempts = 1`、失败和退款语义收口。 +- 子 worker 崩溃或重启:当前内部 RPC 失败;父侧不查询、不恢复、不重发同一次 RPC。 +- 子 worker 成功但响应在网络中丢失:结果视为未知;flat 进入原 fallback,complex 失败。 +- 子 worker 不得反向 complete / fail 父 job,也不得写画布、业务资源或账单。 + +### 7.3 动画首版范围 + +动画继续让最多 `48` 个 frame future 提交内部 HTTP,并保持当前 collect / drain 行为:所有已提交的帧调用都等待自己的成功、失败或 deadline 后,父流程再按稳定帧序号选择根因并失败。首版不增加 `groupId`、跨请求 registry 或 cancel endpoint,也不宣称“某一帧失败后立即取消仍在 worker 排队的兄弟帧”。 + +这是为了把改动限制在低层 BgFilter 调用和调度服务。若真实观测证明排队兄弟帧浪费严重,可在第二阶段增加纯内存 `groupId + CancellationToken`:取消尚未取得 permit 的请求,已经开始的 provider attempt 仍排空。该升级仍不需要数据库表,但需要同时修改父侧动画收集逻辑,因此不并入首版。 + +## 8. 业务语义保持 + +| 场景 | 内部响应 | 父流程行为 | +| --- | --- | --- | +| flat 单图 / 动画帧 | 图片二进制 | 继续现有 Alpha / 尺寸恢复、finalizer 和最终持久化 | +| flat,父业务预算仍有效 | `provider_exhausted / circuit_open / deadline_exceeded / overloaded / invalid_result / internal_error` 或内部断连 | 记录对应故障后进入现有“阿里云通用抠图 → 本地键色”;这些内部错误本身不都计入熔断 | +| flat | `cancelled`,或父 job cancellation / 绝对 deadline 已生效 | 立即向上退出,不再启动阿里云或本地 fallback | +| flat | `invalid_request / unauthorized` | 作为内部契约或部署配置错误失败,不 fallback、不计入 BgFilter 熔断 | +| complex 手动去背景 | 图片二进制 | 父流程继续最终 OSS、资源和画布写回 | +| complex 手动去背景 | 任意非成功或断连 | 父流程直接失败;不得接 flat fallback,不得修改 flat 熔断 | +| 角色 / 图标 / UI 后处理最终失败 | BgFilter 与 fallback 都未得到可用结果 | 保留已持久化 provider 原图,以现有 `completed + warning` 收口 | +| 动画任一帧最终失败 | 该帧完整 fallback / finalizer / PUT 仍失败 | 排空其它已提交帧后,整项动画按现有语义失败退款 | + +父侧移除现有 flat / complex BgFilter retry loop 和本地 flat 熔断,避免父侧两次 × 子 worker 两次变成四次。所有入口只替换共同的低层 BgFilter helper;这样 External v1 直接调用 `_for_owner` 的路径也会自然经过内部 worker。 + +BgFilter 成功二进制不是一份新的业务资产: + +- 不创建 raw object key,也没有 raw 结果 TTL。 +- 父侧按现有路径消费字节并写最终 OSS;手动 complex 只写最终结果一次。 +- 角色、图标、UI 的 provider 原图和动画源帧继续沿用现有对象生命周期,不归子 worker 清理。 + +## 9. 安全、内存与可观测性 + +### 9.1 内部安全 + +- 首版限定父 worker 与子 worker 同机部署,内部 listener 只绑定 loopback 固定端口,不挂公共 Axum router、Nginx、BFF 或 OpenAPI。 +- 使用独立内部 Token;缺失时生产 fail-closed。Token 不复用 BgFilter provider Token。 +- 只接受配置 bucket 下的规范化 object key;禁止 `http://`、`https://`、`data:`、`blob:` 和路径逃逸。 +- 不在日志、trace、metrics、错误 JSON 或 SpacetimeDB 审计中写签名 URL、Token、图片字节或 Base64。 + +### 9.2 图片与内存边界 + +父、子两侧都复用当前保护: + +- 响应体最大 `32 MiB`,有 `Content-Length` 和 chunked 两种路径都累计限长; +- 解码宽高最大 `8192 × 8192`,限制解码分配; +- MIME、魔数和实际解码结果必须一致; +- 空 body、截断 body 和超限图片按 `invalid_result` 处理。 + +二进制跨进程传输期间,子 worker 和父 worker 可能同时持有同一张图片。前述 `N / Q` permit 必须由 response-body guard 持有到发送完成或 body drop,才能把子 worker 中的完整成功 body 控制在 `N` 级;在此前提下,极端内存至少按 `2 × N × 32 MiB` 再加解码缓冲评估。若实现没有该 guard,完整 body 可能积累到 `Q` 级,本文的内存模型即不成立,不能上线。生产 `N / Q` 必须结合主机内存压测,而不是只看 BgFilter 吞吐。 + +### 9.3 指标与日志 + +最小指标: + +- `bgfilter_internal_waiting_requests` +- `bgfilter_internal_in_flight` +- `bgfilter_internal_request_seconds{mode,outcome}` +- `bgfilter_provider_http_seconds{mode,attempt,outcome}` +- `bgfilter_internal_request_total{mode,outcome}` +- `bgfilter_internal_response_bytes` +- `bgfilter_circuit_state` + +日志只写 `requestId`、父 job / request correlation、mode、attempt、排队耗时、provider 耗时、结果码和安全 object key;不得记录请求/响应图片 body。 + +当前 flat 的每次 provider 失败审计必须迁到子 worker,保留“第一次失败、第二次成功”也可观察的事实;内部 admission、鉴权和本地配置错误不伪装成 BgFilter provider 失败。 + +## 10. 实施与部署计划 + +### 10.1 实施顺序 + +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 不自动重试,把父剩余预算显式转换为较短的 `requestBudgetMs` 和略长的 client timeout,并保证两者都早于父绝对 deadline。 +5. 用内部 client 替换两个集中调用边界: + - flat:`remove_editor_generated_screen_background_with_bgfilter_with_request_timeout`; + - complex:`request_editor_background_removal_image_with_retry`。 +6. 从父侧移除 BgFilter provider retry 和 flat 熔断实现;保留 flat fallback、complex 失败、Alpha / 尺寸恢复和所有最终持久化。 +7. 删除动画 `2000ms × frame_count` 单 attempt timeout 增量;保留现有所有帧 collect / drain。 +8. 扫描 queue、inline、External v1 的角色、图标、UI、动画和手动 complex 路径,确认没有 direct BgFilter HTTP 旁路。 +9. 增加单实例 systemd unit、内部地址 / Token、`N / Q` 配置、readiness 和指标;真实压测 `32 / 40 / 48` 帧后启用。 + +本计划不产生 SpacetimeDB schema、migration、bindings 或表目录改动。 + +### 10.2 部署与回滚 + +1. 先部署并启动唯一 `bgfilter-worker`,确认 loopback health、鉴权和 provider smoke。 +2. 再发布使用内部 client 的 API / external-generation-worker。 +3. 确认所有父进程只访问内部 endpoint,BgFilter provider 日志中不再出现父进程直连。 +4. 发布子 worker 时执行 `stop old -> 等待排空/退出 -> start new`,不得滚动重叠。 + +内部 worker 不可用时禁止自动 direct fallback。flat 仍可走业务已有阿里云 / 本地 fallback;complex 明确失败。需要整体回滚时回滚父、子进程版本和配置,不在运行中混用两种 BgFilter 调度方式。 + +## 11. 验收门禁 + +必须覆盖: + +- `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。 +- `requestBudgetMs + parent transport window <= parent client timeout <= 父剩余绝对预算`,worker 有时间把类型化错误交回父侧。 +- 第一次失败后预算不足时不开始第二次;父侧从不重试整次内部 RPC。 +- 父业务预算仍有效时,flat 两次失败、熔断、overload、内部 RPC deadline 或断连仍走“阿里云 → 本地”;complex 任意失败直接失败且不读写熔断。 +- flat 熔断按真实失败 attempt 计数;由剩余业务预算截短的 timeout 不计入。已获准调用可完成第二次,后续排队请求快速 `circuit_open`。 +- `cancelled`、父 cancellation / 绝对 deadline、`invalid_request` 和 `unauthorized` 不启动 flat fallback;其它 flat 错误只在父业务预算仍有效时进入 fallback。 +- 客户端断连时,等待 permit 的请求最终由 deadline 收口;已开始 provider attempt 持有 permit 并排空。明确 cancellation 已被观察到后不再开始第二次,单纯 TCP 断连只作 best-effort 测试,不作为硬保证。 +- 动画全部已提交帧继续 collect / drain,根因按现有稳定帧序号收口;首版不存在未实现的 group cancellation 承诺。 +- 成功 body 为原始二进制而非 Base64;父、子两侧都拒绝空 body、MIME / 魔数不一致、chunked 超 `32 MiB` 和超过 `8192 × 8192` 的图片。 +- `N / Q` response-body guard 在发送完成或 body drop 前不释放,慢读 / 断连时完整成功 body 不积累到 `Q` 级。 +- worker 重启 / RPC 丢失不查询、不恢复结果;父 job 的 heartbeat、lease、失败退款和 fencing 保持现状。 +- External v1 / inline 不再直连 BgFilter;公共 router、BFF、账单和任务列表中没有内部 endpoint 或内部调用记录。 +- 生产不存在两个同时运行的 `bgfilter-worker`,配置缺失或 `N / Q = 0` 时 fail-closed。 + +实现后按范围运行: + +```bash +cargo test -p api-server bgfilter --manifest-path server-rs/Cargo.toml +cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml +cargo check -p api-server --manifest-path server-rs/Cargo.toml +npm run check:server-rs-ddd +npm run check:encoding +git diff --check +``` + +不需要运行 `npm run spacetime:generate` 或 `npm run check:spacetime-schema`,因为本计划明确不修改 SpacetimeDB。 + +## 12. 实施前需冻结的参数 + +架构边界已固定:首版单实例、无 QPS、无数据库任务表、无 checkpoint / continuation、输出直接走内部二进制响应。 + +编码前只需冻结两个容量值: + +- 生产 `GENARRATIVE_BGFILTER_WORKER_CONCURRENCY = N`。 +- 生产 `GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS = Q`。若要求一个满帧动画不因自身 admission 被拒绝,`Q` 至少为 `48`;候选 `128` 可容纳两个满帧动画并留少量余量,但明确不能覆盖 controller 理论最大 `768` 次同时提交,超出部分会按 mode fallback 或失败。最终值以可接受的 overload 行为和内存压测为准。 + +其余首版固定边界:provider 单 attempt 默认 `180s`,内部响应最大 `32 MiB / 8192 × 8192`,内部 listener 只绑定 loopback 且必须鉴权。若要多实例、严格服务端全局并发、QPS 或动画 peer cancellation,应先升级本文,不在编码中临时扩 scope。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 4eb3b79dc..a7fb40fcb 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -2,7 +2,9 @@ > 2026-07-18 退役覆盖:旧创作模板 job 类型、玩法写回和玩法恢复链路均已退出现役 worker。当前 worker 只领取 `source_module = editor-canvas` 的任务;本文涉及拼图、跳一跳、拼消消、敲木鱼等玩法的内容仅作为历史设计记录,历史队列行不得被领取或改写。 -更新时间:`2026-07-15` +> 2026-07-21 待实施专题:BgFilter 作为受限内部资源,将成为“单用户动作一个外部生成 job”的受控内部例外。用户可见层仍只有父 `external_generation_job`;父 future 保持原 lease 和 attempt,在当前调用栈内同步请求唯一 `bgfilter-worker` 的内部 HTTP,成功图片字节直接返回父流程。首版不新增 SpacetimeDB 子任务表、父 checkpoint / continuation 或 raw 中间结果 OSS。完整边界见 [`BgFilter 受限资源调度方案(同步内部 HTTP 原地等待版)`](./【后端架构】BgFilter受限资源调度方案-2026-07-21.md)。 + +更新时间:`2026-07-21` ## 背景