feat: add inline external generation mode
This commit is contained in:
@@ -52,6 +52,7 @@
|
||||
|
||||
- 背景:拼图首图、图集、音频等外部生成链路长期占用 `api-server` HTTP handler,导致扩容只能放大 API 进程,且 HTTP 超时和外部 provider 波动会直接影响创作入口。
|
||||
- 决策:外部生成任务统一进入 SpacetimeDB `external_generation_job` 持久队列,由 `api-server` 的 `external-generation-worker` 进程角色 claim lease 后执行;HTTP 角色只做鉴权、表单/状态初始化、入队和返回 `queued/running/completed/failed` 操作状态。生产通过 systemd worker 模板增加实例数或提高 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩容,`GENARRATIVE_PROCESS_ROLE=all` 仅用于本地 smoke。拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与 `generate_puzzle_ui_background` 已接入 worker;业务写回必须在 SpacetimeDB transaction 内校验 `external_generation_job` 的 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,其中首图 worker 的前置 `compile_puzzle_agent_draft` 也必须带 guard。worker 核心业务写回失败不能返回内存快照并把 job 标成 completed;失败态业务写回成功后才能把 job 标成 failed,失败态未写回则保留租约等待后续重领。拼图业务失败不自动重试,只保留 lease 过期后的崩溃重领,避免钱包扣退费幂等漂移。生产发布会启用默认 `genarrative-external-generation-worker@1.service` 并等待 worker active,worker 停机时停止 claim 新任务并 drain 当前任务。
|
||||
- 2026-06-07 追加:`GENARRATIVE_EXTERNAL_GENERATION_MODE` 新增 `queue|inline` 显式策略,默认和生产仍为 `queue`;`inline` 只用于本地或小流量同步排查,由 HTTP handler 复用同一 worker executor 直接返回 `completed`,不创建 `external_generation_job`,不支持 worker 动态扩缩容。拼图写回 guard 字段改为可选,queue 路径仍必须完整校验 `job_id + worker_id + lease_token`;inline 路径只允许三项同时为空,半空 guard 仍拒绝。
|
||||
- 影响范围:`server-rs/crates/spacetime-module/src/external_generation.rs`、`server-rs/crates/spacetime-client/src/external_generation.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`deploy/systemd/genarrative-external-generation-worker@.service`、`scripts/deploy/production-api-deploy.sh`、`scripts/jenkins-server-provision.sh`、拼图 `compile_puzzle_draft`、拼图 `generate_puzzle_images`、拼图 `generate_puzzle_ui_background`、生产 env 模板和运维文档。
|
||||
- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:server-rs-ddd`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`,并用 `GENARRATIVE_PROCESS_ROLE=all npm run dev` smoke 至少一次 queued -> worker 完成链路。
|
||||
- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。
|
||||
|
||||
@@ -16,6 +16,7 @@ Docker Compose
|
||||
|
||||
当前容器模拟参数按 `genarrative-release` 服务器采样值收口为 2 vCPU / 2 GiB RAM / 4096 soft nofile / 768 worker_connections,并已在 compose 里落实到 `spacetimedb cpus=1.0 mem_limit=896m`、`api-server cpus=2.0 mem_limit=1g`、`external-generation-worker cpus=2.0 mem_limit=1g`、`nginx cpus=0.5 mem_limit=128m`、`otelcol cpus=0.25 mem_limit=128m`、`k6 cpus=1.0 mem_limit=512m`。SpacetimeDB 同时设置 `--page_pool_max_size=402653184`,给 reducer、订阅与运行时保留更多非 page pool 内存。
|
||||
容器 `api-server` 默认 `GENARRATIVE_API_WORKER_THREADS=4`,用于让 Tokio 在 2 vCPU 配额内有更多 I/O 调度 worker;该值不会突破 compose 里的 `cpus=2.0` CPU 上限。
|
||||
容器默认 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,用于验证 `api-server -> external_generation_job -> external-generation-worker` 链路;如只想本地同步排查 provider/OSS/SpacetimeDB 写回,可在本机 env 临时改为 `inline`,但该模式不会覆盖 worker 动态扩缩容验证。
|
||||
Collector 镜像使用 `otel/opentelemetry-collector-contrib:0.151.0`。
|
||||
生产服务器若启用 Collector,则由 `deploy/systemd/otelcol-contrib.service` 和 `deploy/otelcol/genarrative-debug.yaml` 托管,不走容器镜像。
|
||||
|
||||
@@ -100,6 +101,8 @@ npm run container:up -- --scale external-generation-worker=3 external-generation
|
||||
npm run container:up -- --scale external-generation-worker=1 external-generation-worker
|
||||
```
|
||||
|
||||
动态扩缩容验证必须保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`;`inline` 模式下生成请求由 `api-server` 同步执行,不会被这些 worker 实例消费。
|
||||
|
||||
停止:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -10,6 +10,8 @@ GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
# 容器 smoke 可临时设 all;压测或预发按 api / external-generation-worker 拆进程。
|
||||
GENARRATIVE_PROCESS_ROLE=api
|
||||
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
|
||||
2
deploy/env/api-server.env.example
vendored
2
deploy/env/api-server.env.example
vendored
@@ -9,6 +9,8 @@ GENARRATIVE_API_LISTEN_BACKLOG=1024
|
||||
GENARRATIVE_API_WORKER_THREADS=4
|
||||
# api 只监听 HTTP;外部生成 worker 用独立进程设置为 external-generation-worker 后横向扩缩。
|
||||
GENARRATIVE_PROCESS_ROLE=api
|
||||
# 默认 queue 进入 external_generation_job;本地/小流量同步排查可显式设 inline。
|
||||
GENARRATIVE_EXTERNAL_GENERATION_MODE=queue
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID=
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY=2
|
||||
GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS=2000
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 外部生成 Worker 化方案
|
||||
|
||||
更新时间:`2026-06-03`
|
||||
更新时间:`2026-06-07`
|
||||
|
||||
## 背景
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
|
||||
## 目标
|
||||
|
||||
- `api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
||||
- 默认 `queue` 模式下,`api-server` 的 HTTP 角色只负责鉴权、入参校验、扣费前置/状态初始化、任务入队和返回 `queued` 操作结果。
|
||||
- 外部生成副作用由独立 `external-generation-worker` 角色执行。
|
||||
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
|
||||
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
|
||||
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。
|
||||
- 已接入拼图 `compile_puzzle_draft` 与结果页 `generate_puzzle_images`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
||||
- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。
|
||||
|
||||
## Module 与 Interface
|
||||
|
||||
@@ -68,9 +69,14 @@ pending/running -> cancelled (预留)
|
||||
|
||||
`claim` 只领取 `pending` 且 `available_at <= now` 的任务,或 `running` 且 `lease_expires_at <= now` 的任务。领取时递增 `attempt`、写入 `worker_id`、`started_at`、新的 `lease_expires_at` 和 `lease_token`。SpacetimeDB procedure 使用 `ctx.timestamp` 作为状态流转时间,只从 worker 入参读取“时长差值”,不信任 worker 本机绝对时间。worker 每次执行只处理自己 claim 到的任务;续租、完成或失败时必须带同一个 `worker_id + lease_token`,且当前 lease 尚未过期,防止过期 worker 覆盖新 lease。
|
||||
|
||||
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
|
||||
玩法业务写回也必须在 SpacetimeDB 同一事务里校验 lease fencing。拼图的 `compile_puzzle_agent_draft` worker 调用、`save_puzzle_generated_images`、`save_puzzle_ui_background`、`mark_puzzle_draft_generation_failed` 和 `mark_puzzle_level_generation_failed` 在 `queue` 模式下会带 `external_generation_job_id / worker_id / lease_token`,并校验 job 仍为 `running`、token 未过期、`job_kind`、`owner_user_id`、`source_module` 和 `source_entity_id` 均匹配后才写 session / work profile。`inline` 模式不创建 `external_generation_job`,因此这三个 guard 字段必须同时为空;transaction 只把三项全空识别为 api-server 受控同步写回,三项半空仍按非法请求拒绝。worker 路径的核心业务写回失败不能返回内存快照并把 job 标为 `completed`;失败态业务回写成功后才允许把队列 job 标为 `failed`,失败态仍未写回时保留当前租约并等待后续 lease 过期重领,避免队列状态和真实 session 脱节。api-server 的资产扣费包装遇到这类 stale worker lease guard 错误时不执行补偿退款,避免旧 worker 冲掉后续合法 worker 的同一账本扣费。
|
||||
|
||||
## 进程角色
|
||||
## 执行模式与进程角色
|
||||
|
||||
外部生成执行模式由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制:
|
||||
|
||||
- `queue`:默认值,HTTP handler 入队 `external_generation_job`,由 `external-generation-worker` 角色 claim lease 后执行;生产、预发和压测默认使用该模式。
|
||||
- `inline`:HTTP handler 直接调用同一个 worker executor,同步等待 provider、OSS 和 SpacetimeDB 写回完成后返回 `operation.status = completed`;只用于本地或低并发排查,不提供队列持久化、lease 重领和 worker 横向扩容。
|
||||
|
||||
同一个 Rust binary 通过 `GENARRATIVE_PROCESS_ROLE` 切换:
|
||||
|
||||
@@ -91,8 +97,8 @@ worker 配置:
|
||||
|
||||
`compile_puzzle_draft`:
|
||||
|
||||
1. HTTP handler 保存拼图表单草稿;`queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
|
||||
2. HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。
|
||||
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
|
||||
2. `queue` 模式下 HTTP handler 入队 `puzzle_compile_draft`,返回 `operation.status = queued` 和当前 session。拼图 dedupe key 包含本次 `extgen-` job id,只保证同一任务行唯一,不把同一 session 后续重新生成吞掉。`inline` 模式下 HTTP handler 复用同一 executor 同步执行,成功后直接返回 `completed` 和最新 session。
|
||||
3. 前端保持 `puzzle-generating`,继续轮询 `getPuzzleAgentSession`;首期不把 `queued/running` 写回 `puzzle_agent_session`,因此刷新或跨设备恢复生成中状态仍是后续 read model 工作。
|
||||
4. worker claim 后执行原有 `compile_puzzle_draft_with_initial_cover` 或 `compile_puzzle_draft_with_uploaded_cover`;前置 `compile_puzzle_agent_draft` 也必须携带本次 `job_id / worker_id / lease_token`,防止过期 worker 先把草稿卡和 session 写到 ready。
|
||||
5. 成功后沿原有 SpacetimeDB 拼图会话/作品写回,前端轮询看到 `progressPercent >= 94/96/100` 和 ready 草稿。
|
||||
@@ -100,14 +106,14 @@ worker 配置:
|
||||
|
||||
`generate_puzzle_images`:
|
||||
|
||||
1. HTTP handler 校验本次 `levelsJson` 快照后入队 `puzzle_generate_images`,返回 `operation.status = queued/running/completed/failed`。
|
||||
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_images` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
||||
2. worker 执行原结果页关卡图链路:自动命名、VectorEngine / 上传图直用、关卡场景图、UI spritesheet、关卡背景资产包、OSS 持久化和 SpacetimeDB 回写。
|
||||
3. 成功后 `save_puzzle_generated_images` 写回目标关卡和草稿卡;失败后 `mark_puzzle_level_generation_failed` 只标记目标关卡 `failed`,不污染已 ready 的其它关卡。队列 job 只有在目标关卡失败态写回成功后才进入 failed。
|
||||
4. 前端结果页对 `queued/running` 操作继续轮询 `getPuzzleAgentSession`,目标关卡变为 ready 或 failed 后收敛。
|
||||
|
||||
`generate_puzzle_ui_background`:
|
||||
|
||||
1. HTTP handler 校验本次 `levelsJson` 快照后入队 `puzzle_generate_ui_background`,返回 `operation.status = queued/running/completed/failed`。
|
||||
1. HTTP handler 校验本次 `levelsJson` 快照;`queue` 模式下入队 `puzzle_generate_ui_background` 并返回 `operation.status = queued/running/completed/failed`,`inline` 模式下同步执行原 worker executor 并在成功后返回 `completed`。
|
||||
2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
|
||||
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。
|
||||
|
||||
@@ -141,7 +147,7 @@ GENARRATIVE_PROCESS_ROLE=all npm run dev
|
||||
curl -f http://127.0.0.1:<api-port>/healthz
|
||||
```
|
||||
|
||||
生产 smoke 需要至少启动一个 `api` 角色和一个 `external-generation-worker` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,并等待 worker active。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。
|
||||
本地同步排查可显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline npm run dev:api-server`,用于确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行;该模式不覆盖 worker 队列 smoke。生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色和一个 `external-generation-worker` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,并等待 worker active。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。
|
||||
|
||||
systemd 生产扩缩容示例:
|
||||
|
||||
|
||||
@@ -207,7 +207,7 @@ npm run check:server-rs-ddd
|
||||
|
||||
- Rust 结构体:`ExternalGenerationJob`
|
||||
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
|
||||
- 用途:外部生成 worker 的持久任务队列;`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
- 用途:外部生成 worker 的持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写完成 / 失败。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
|
||||
|
||||
### `ai_text_chunk`
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模
|
||||
|
||||
开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。
|
||||
|
||||
本地排查外部内容生成 worker 时,可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列。该模式只用于 smoke;生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费。拼图首图 `compile_puzzle_draft`、结果页关卡图片 `generate_puzzle_images` 和结果页 UI 背景 `generate_puzzle_ui_background` 都走该队列;worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。
|
||||
本地排查外部内容生成 worker 时,可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列。该模式只用于 smoke;生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费。外部生成执行策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制,默认 `queue` 会让拼图首图 `compile_puzzle_draft`、结果页关卡图片 `generate_puzzle_images` 和结果页 UI 背景 `generate_puzzle_ui_background` 进入持久队列;worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。本地或小流量排查同步等待链路时可显式设为 `inline`,由 `api-server` handler 直接复用 worker executor 并在完成后返回 `completed`,不创建 `external_generation_job`,也不提供动态扩缩容能力。
|
||||
|
||||
微信小程序虚拟支付使用 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_OFFER_ID`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_APP_KEY`、`WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_SANDBOX_APP_KEY` 和 `WECHAT_MINI_PROGRAM_VIRTUAL_PAYMENT_ENV` 配置。小程序充值统一走 `wechat_mp_virtual` / `wx.requestVirtualPayment`:泥点属于代币(`coin`),`buyQuantity` 按当前充值商品快照里的 `points_amount` 传;会员和后台新增道具类商品走 `short_series_goods`,`productId` 对应微信后台道具 ID。旧登录快照若缺 `session_key`,需要用户在小程序内重新登录后再支付;客户端成功回调不是最终到账,仍以后端通知或查询确认订单为准。详细口径见 `docs/【技术方案】微信虚拟支付接入-2026-05-26.md`。
|
||||
|
||||
@@ -257,7 +257,7 @@ Jenkins 按 web / api / Spacetime module / build / deploy / publish 拆分
|
||||
|
||||
生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。
|
||||
|
||||
`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`all` 仅用于本地或临时 smoke。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;扩容时增加 worker 进程或提高单进程并发,缩容时停止多余 worker。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会默认 enable 首个 `genarrative-external-generation-worker@1.service`,并在已存在 `/opt/genarrative/current/api-server` 时随 API 一起重启;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active。手动持久化首个实例可用 `systemctl enable --now genarrative-external-generation-worker@1.service`,横向扩容用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。当前拼图首关生成只做 lease 崩溃重领,不做业务失败自动重试,避免 worker 退款和重试成功之间产生钱包账本漂移。
|
||||
`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`all` 仅用于本地或临时 smoke。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制,生产和容器压测默认保持 `queue`;`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;扩容时增加 worker 进程或提高单进程并发,缩容时停止多余 worker。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会默认 enable 首个 `genarrative-external-generation-worker@1.service`,并在已存在 `/opt/genarrative/current/api-server` 时随 API 一起重启;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active。手动持久化首个实例可用 `systemctl enable --now genarrative-external-generation-worker@1.service`,横向扩容用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。当前拼图首关生成只做 lease 崩溃重领,不做业务失败自动重试,避免 worker 退款和重试成功之间产生钱包账本漂移。
|
||||
|
||||
`Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等构建链依赖。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct AppConfig {
|
||||
pub listen_backlog: i32,
|
||||
pub worker_threads: Option<usize>,
|
||||
pub process_role: ProcessRole,
|
||||
pub external_generation_mode: ExternalGenerationMode,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_worker_concurrency: usize,
|
||||
pub external_generation_worker_poll_interval: Duration,
|
||||
@@ -171,6 +172,25 @@ pub enum ProcessRole {
|
||||
All,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum ExternalGenerationMode {
|
||||
Inline,
|
||||
Queue,
|
||||
}
|
||||
|
||||
impl ExternalGenerationMode {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Inline => "inline",
|
||||
Self::Queue => "queue",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_inline(self) -> bool {
|
||||
matches!(self, Self::Inline)
|
||||
}
|
||||
}
|
||||
|
||||
impl ProcessRole {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
@@ -197,6 +217,7 @@ impl Default for AppConfig {
|
||||
listen_backlog: 1024,
|
||||
worker_threads: None,
|
||||
process_role: ProcessRole::Api,
|
||||
external_generation_mode: ExternalGenerationMode::Queue,
|
||||
external_generation_worker_id: default_external_generation_worker_id(),
|
||||
external_generation_worker_concurrency: 2,
|
||||
external_generation_worker_poll_interval: Duration::from_millis(2_000),
|
||||
@@ -385,6 +406,11 @@ impl AppConfig {
|
||||
if let Some(process_role) = read_first_process_role_env(&["GENARRATIVE_PROCESS_ROLE"]) {
|
||||
config.process_role = process_role;
|
||||
}
|
||||
if let Some(external_generation_mode) =
|
||||
read_first_external_generation_mode_env(&["GENARRATIVE_EXTERNAL_GENERATION_MODE"])
|
||||
{
|
||||
config.external_generation_mode = external_generation_mode;
|
||||
}
|
||||
if let Some(worker_id) =
|
||||
read_first_non_empty_env(&["GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID"])
|
||||
{
|
||||
@@ -1046,6 +1072,14 @@ fn read_first_process_role_env(keys: &[&str]) -> Option<ProcessRole> {
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_external_generation_mode_env(keys: &[&str]) -> Option<ExternalGenerationMode> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
.ok()
|
||||
.and_then(|value| parse_external_generation_mode(&value))
|
||||
})
|
||||
}
|
||||
|
||||
fn read_first_positive_u32_env(keys: &[&str]) -> Option<u32> {
|
||||
keys.iter().find_map(|key| {
|
||||
env::var(key)
|
||||
@@ -1111,6 +1145,16 @@ fn parse_process_role(value: &str) -> Option<ProcessRole> {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_external_generation_mode(value: &str) -> Option<ExternalGenerationMode> {
|
||||
match trim_quoted_env_value(value).to_ascii_lowercase().as_str() {
|
||||
"inline" | "sync" | "synchronous" => Some(ExternalGenerationMode::Inline),
|
||||
"queue" | "queued" | "worker" | "async" | "asynchronous" => {
|
||||
Some(ExternalGenerationMode::Queue)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_quoted_env_value(raw: &str) -> &str {
|
||||
let raw = raw.trim();
|
||||
raw.strip_prefix('"')
|
||||
@@ -1243,8 +1287,8 @@ fn parse_positive_u16(raw: &str) -> Option<u16> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, LlmProvider, ProcessRole,
|
||||
parse_bool, parse_process_role,
|
||||
AppConfig, DEFAULT_VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS, ExternalGenerationMode,
|
||||
LlmProvider, ProcessRole, parse_bool, parse_external_generation_mode, parse_process_role,
|
||||
};
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -1312,6 +1356,51 @@ mod tests {
|
||||
assert!(ProcessRole::All.runs_external_generation_worker());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_generation_mode_parses_inline_and_queue_aliases() {
|
||||
assert_eq!(
|
||||
parse_external_generation_mode("inline"),
|
||||
Some(ExternalGenerationMode::Inline)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_generation_mode("'sync'"),
|
||||
Some(ExternalGenerationMode::Inline)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_generation_mode("\"queue\""),
|
||||
Some(ExternalGenerationMode::Queue)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_external_generation_mode("worker"),
|
||||
Some(ExternalGenerationMode::Queue)
|
||||
);
|
||||
assert_eq!(parse_external_generation_mode("unknown"), None);
|
||||
|
||||
assert!(ExternalGenerationMode::Inline.is_inline());
|
||||
assert!(!ExternalGenerationMode::Queue.is_inline());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_external_generation_mode() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
unsafe {
|
||||
std::env::set_var("GENARRATIVE_EXTERNAL_GENERATION_MODE", "inline");
|
||||
}
|
||||
|
||||
let config = AppConfig::from_env();
|
||||
|
||||
assert_eq!(
|
||||
config.external_generation_mode,
|
||||
ExternalGenerationMode::Inline
|
||||
);
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_EXTERNAL_GENERATION_MODE");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_sms_enabled_when_shell_value_keeps_quotes() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
@@ -77,17 +77,13 @@ pub fn resolve_creation_entry_route_id(path: &str) -> Option<&'static str> {
|
||||
{
|
||||
return Some("puzzle");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/puzzle/gallery/")
|
||||
&& normalized.ends_with("/remix")
|
||||
{
|
||||
if normalized.starts_with("/api/runtime/puzzle/gallery/") && normalized.ends_with("/remix") {
|
||||
return Some("puzzle");
|
||||
}
|
||||
if normalized == "/api/runtime/big-fish/agent/sessions" {
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized.starts_with("/api/runtime/big-fish/gallery/")
|
||||
&& normalized.ends_with("/remix")
|
||||
{
|
||||
if normalized.starts_with("/api/runtime/big-fish/gallery/") && normalized.ends_with("/remix") {
|
||||
return Some("big-fish");
|
||||
}
|
||||
if normalized == "/api/runtime/custom-world/agent/sessions"
|
||||
|
||||
@@ -496,11 +496,11 @@ fn build_external_generation_write_lease_guard(
|
||||
worker_id: &str,
|
||||
job: &ExternalGenerationJobRecord,
|
||||
) -> Result<ExternalGenerationWriteLeaseGuard, String> {
|
||||
Ok(ExternalGenerationWriteLeaseGuard {
|
||||
job_id: job.job_id.clone(),
|
||||
worker_id: worker_id.to_string(),
|
||||
lease_token: require_job_lease_token(job)?,
|
||||
})
|
||||
Ok(ExternalGenerationWriteLeaseGuard::from_claimed_job(
|
||||
job.job_id.clone(),
|
||||
worker_id.to_string(),
|
||||
require_job_lease_token(job)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn duration_micros_i64(duration: Duration) -> i64 {
|
||||
@@ -527,9 +527,9 @@ mod tests {
|
||||
let guard = build_external_generation_write_lease_guard("worker-a", &job)
|
||||
.expect("guard should build");
|
||||
|
||||
assert_eq!(guard.job_id, "extgen-1");
|
||||
assert_eq!(guard.worker_id, "worker-a");
|
||||
assert_eq!(guard.lease_token, "lease-1");
|
||||
assert_eq!(guard.job_id.as_deref(), Some("extgen-1"));
|
||||
assert_eq!(guard.worker_id.as_deref(), Some("worker-a"));
|
||||
assert_eq!(guard.lease_token.as_deref(), Some("lease-1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -136,9 +136,27 @@ const PUZZLE_VECTOR_ENGINE_PORTRAIT_IMAGE_SIZE: &str = "1024x1536";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ExternalGenerationWriteLeaseGuard {
|
||||
pub(crate) job_id: String,
|
||||
pub(crate) worker_id: String,
|
||||
pub(crate) lease_token: String,
|
||||
pub(crate) job_id: Option<String>,
|
||||
pub(crate) worker_id: Option<String>,
|
||||
pub(crate) lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl ExternalGenerationWriteLeaseGuard {
|
||||
pub(crate) fn inline() -> Self {
|
||||
Self {
|
||||
job_id: None,
|
||||
worker_id: None,
|
||||
lease_token: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_claimed_job(job_id: String, worker_id: String, lease_token: String) -> Self {
|
||||
Self {
|
||||
job_id: Some(job_id),
|
||||
worker_id: Some(worker_id),
|
||||
lease_token: Some(lease_token),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -166,6 +184,10 @@ impl PuzzleExternalGenerationWorkerError {
|
||||
self.error.body_text()
|
||||
}
|
||||
|
||||
pub(crate) fn into_app_error(self) -> AppError {
|
||||
self.error
|
||||
}
|
||||
|
||||
pub(crate) fn should_fail_queue_job(&self) -> bool {
|
||||
self.should_fail_queue_job
|
||||
}
|
||||
|
||||
@@ -648,6 +648,53 @@ pub async fn execute_puzzle_agent_action(
|
||||
image_model: payload.image_model.clone(),
|
||||
requested_at_micros: now,
|
||||
};
|
||||
if state
|
||||
.root_state()
|
||||
.config
|
||||
.external_generation_mode
|
||||
.is_inline()
|
||||
{
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %compile_session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
external_generation_mode = state.root_state().config.external_generation_mode.as_str(),
|
||||
"拼图首关草稿生成使用 inline 模式同步执行"
|
||||
);
|
||||
let session = execute_puzzle_compile_draft_worker_job(
|
||||
&state,
|
||||
&request_context,
|
||||
worker_payload,
|
||||
ExternalGenerationWriteLeaseGuard::inline(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
error.into_app_error(),
|
||||
)
|
||||
})?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: build_prefixed_uuid_id("extgen-inline-"),
|
||||
operation_type: "compile_puzzle_draft".to_string(),
|
||||
status: "completed".to_string(),
|
||||
phase_label: "首关拼图草稿".to_string(),
|
||||
phase_detail: if ai_redraw {
|
||||
"首关草稿生成已完成。".to_string()
|
||||
} else {
|
||||
"首关草稿编译已完成。".to_string()
|
||||
},
|
||||
progress: 100,
|
||||
error: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
));
|
||||
}
|
||||
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
@@ -811,6 +858,49 @@ pub async fn execute_puzzle_agent_action(
|
||||
levels_json,
|
||||
requested_at_micros: now,
|
||||
};
|
||||
if state
|
||||
.root_state()
|
||||
.config
|
||||
.external_generation_mode
|
||||
.is_inline()
|
||||
{
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
external_generation_mode = state.root_state().config.external_generation_mode.as_str(),
|
||||
"拼图关卡图片生成使用 inline 模式同步执行"
|
||||
);
|
||||
let session = execute_puzzle_generate_images_worker_job(
|
||||
&state,
|
||||
&request_context,
|
||||
worker_payload,
|
||||
ExternalGenerationWriteLeaseGuard::inline(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
error.into_app_error(),
|
||||
)
|
||||
})?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: build_prefixed_uuid_id("extgen-inline-"),
|
||||
operation_type: "generate_puzzle_images".to_string(),
|
||||
status: "completed".to_string(),
|
||||
phase_label: "拼图图片生成".to_string(),
|
||||
phase_detail: "关卡图片生成已完成。".to_string(),
|
||||
progress: 100,
|
||||
error: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
));
|
||||
}
|
||||
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
@@ -908,6 +998,49 @@ pub async fn execute_puzzle_agent_action(
|
||||
levels_json,
|
||||
requested_at_micros: now,
|
||||
};
|
||||
if state
|
||||
.root_state()
|
||||
.config
|
||||
.external_generation_mode
|
||||
.is_inline()
|
||||
{
|
||||
tracing::info!(
|
||||
provider = PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
session_id = %session_id,
|
||||
owner_user_id = %owner_user_id,
|
||||
external_generation_mode = state.root_state().config.external_generation_mode.as_str(),
|
||||
"拼图 UI 背景图生成使用 inline 模式同步执行"
|
||||
);
|
||||
let session = execute_puzzle_generate_ui_background_worker_job(
|
||||
&state,
|
||||
&request_context,
|
||||
worker_payload,
|
||||
ExternalGenerationWriteLeaseGuard::inline(),
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
PUZZLE_AGENT_API_BASE_PROVIDER,
|
||||
error.into_app_error(),
|
||||
)
|
||||
})?;
|
||||
return Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
PuzzleAgentActionResponse {
|
||||
operation: PuzzleAgentOperationResponse {
|
||||
operation_id: build_prefixed_uuid_id("extgen-inline-"),
|
||||
operation_type: "generate_puzzle_ui_background".to_string(),
|
||||
status: "completed".to_string(),
|
||||
phase_label: "UI 背景图生成".to_string(),
|
||||
phase_detail: "拼图 UI 背景图生成已完成。".to_string(),
|
||||
progress: 100,
|
||||
error: None,
|
||||
},
|
||||
session: map_puzzle_agent_session_response(session),
|
||||
},
|
||||
));
|
||||
}
|
||||
let request_payload_json = serde_json::to_string(&worker_payload).map_err(|error| {
|
||||
puzzle_error_response(
|
||||
&request_context,
|
||||
|
||||
@@ -78,9 +78,9 @@ pub struct PuzzleDraftCompileFailureInput {
|
||||
pub owner_user_id: String,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -92,9 +92,9 @@ pub struct PuzzleLevelGenerationFailureInput {
|
||||
pub levels_json: Option<String>,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -106,9 +106,9 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub levels_json: Option<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
@@ -122,9 +122,9 @@ pub struct PuzzleUiBackgroundSaveInput {
|
||||
pub image_src: String,
|
||||
pub image_object_key: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))]
|
||||
|
||||
@@ -642,9 +642,9 @@ pub struct PuzzleDraftCompileFailureRecordInput {
|
||||
pub owner_user_id: String,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -655,9 +655,9 @@ pub struct PuzzleLevelGenerationFailureRecordInput {
|
||||
pub levels_json: Option<String>,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -668,9 +668,9 @@ pub struct PuzzleGeneratedImagesSaveRecordInput {
|
||||
pub levels_json: Option<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -683,9 +683,9 @@ pub struct PuzzleUiBackgroundSaveRecordInput {
|
||||
pub image_src: String,
|
||||
pub image_object_key: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -11,9 +11,9 @@ pub struct PuzzleDraftCompileFailureInput {
|
||||
pub owner_user_id: String,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleDraftCompileFailureInput {
|
||||
|
||||
@@ -13,9 +13,9 @@ pub struct PuzzleGeneratedImagesSaveInput {
|
||||
pub levels_json: Option<String>,
|
||||
pub candidates_json: String,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleGeneratedImagesSaveInput {
|
||||
|
||||
@@ -13,9 +13,9 @@ pub struct PuzzleLevelGenerationFailureInput {
|
||||
pub levels_json: Option<String>,
|
||||
pub error_message: String,
|
||||
pub failed_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleLevelGenerationFailureInput {
|
||||
|
||||
@@ -15,9 +15,9 @@ pub struct PuzzleUiBackgroundSaveInput {
|
||||
pub image_src: String,
|
||||
pub image_object_key: Option<String>,
|
||||
pub saved_at_micros: i64,
|
||||
pub external_generation_job_id: String,
|
||||
pub external_generation_worker_id: String,
|
||||
pub external_generation_lease_token: String,
|
||||
pub external_generation_job_id: Option<String>,
|
||||
pub external_generation_worker_id: Option<String>,
|
||||
pub external_generation_lease_token: Option<String>,
|
||||
}
|
||||
|
||||
impl __sdk::InModule for PuzzleUiBackgroundSaveInput {
|
||||
|
||||
@@ -147,8 +147,13 @@ impl SpacetimeClient {
|
||||
owner_user_id: String,
|
||||
compiled_at_micros: i64,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
self.compile_puzzle_agent_draft_inner(session_id, owner_user_id, compiled_at_micros, None)
|
||||
.await
|
||||
self.compile_puzzle_agent_draft_inner(
|
||||
session_id,
|
||||
owner_user_id,
|
||||
compiled_at_micros,
|
||||
(None, None, None),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compile_puzzle_agent_draft_with_external_generation_guard(
|
||||
@@ -156,19 +161,19 @@ impl SpacetimeClient {
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
compiled_at_micros: i64,
|
||||
external_generation_job_id: String,
|
||||
external_generation_worker_id: String,
|
||||
external_generation_lease_token: String,
|
||||
external_generation_job_id: Option<String>,
|
||||
external_generation_worker_id: Option<String>,
|
||||
external_generation_lease_token: Option<String>,
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
self.compile_puzzle_agent_draft_inner(
|
||||
session_id,
|
||||
owner_user_id,
|
||||
compiled_at_micros,
|
||||
Some((
|
||||
(
|
||||
external_generation_job_id,
|
||||
external_generation_worker_id,
|
||||
external_generation_lease_token,
|
||||
)),
|
||||
),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -178,17 +183,13 @@ impl SpacetimeClient {
|
||||
session_id: String,
|
||||
owner_user_id: String,
|
||||
compiled_at_micros: i64,
|
||||
external_generation_guard: Option<(String, String, String)>,
|
||||
external_generation_guard: (Option<String>, Option<String>, Option<String>),
|
||||
) -> Result<PuzzleAgentSessionRecord, SpacetimeClientError> {
|
||||
let (
|
||||
external_generation_job_id,
|
||||
external_generation_worker_id,
|
||||
external_generation_lease_token,
|
||||
) = external_generation_guard
|
||||
.map(|(job_id, worker_id, lease_token)| {
|
||||
(Some(job_id), Some(worker_id), Some(lease_token))
|
||||
})
|
||||
.unwrap_or((None, None, None));
|
||||
) = external_generation_guard;
|
||||
let procedure_input = PuzzleDraftCompileInput {
|
||||
session_id,
|
||||
owner_user_id,
|
||||
|
||||
@@ -1003,26 +1003,17 @@ fn compile_puzzle_agent_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
match (
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
) {
|
||||
(Some(job_id), Some(worker_id), Some(lease_token)) => {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
job_id,
|
||||
worker_id,
|
||||
lease_token,
|
||||
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
None,
|
||||
)?;
|
||||
}
|
||||
(None, None, None) => {}
|
||||
_ => return Err("拼图草稿编译外部生成 guard 不完整".to_string()),
|
||||
}
|
||||
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
None,
|
||||
"拼图草稿编译外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
if row.seed_text.trim().is_empty() {
|
||||
return Err("请先填写拼图作品信息".to_string());
|
||||
@@ -1073,15 +1064,16 @@ fn mark_puzzle_draft_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleDraftCompileFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[PUZZLE_COMPILE_DRAFT_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
None,
|
||||
"拼图草稿失败态外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
|
||||
@@ -1138,11 +1130,11 @@ fn mark_puzzle_level_generation_failed_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleLevelGenerationFailureInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND,
|
||||
@@ -1150,6 +1142,7 @@ fn mark_puzzle_level_generation_failed_tx(
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
"拼图关卡失败态外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let updated_at = Timestamp::from_micros_since_unix_epoch(input.failed_at_micros);
|
||||
@@ -1259,6 +1252,35 @@ fn validate_puzzle_external_generation_write_guard(
|
||||
)
|
||||
}
|
||||
|
||||
fn validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx: &TxContext,
|
||||
job_id: Option<&str>,
|
||||
worker_id: Option<&str>,
|
||||
lease_token: Option<&str>,
|
||||
expected_job_kinds: &[&str],
|
||||
session_id: &str,
|
||||
owner_user_id: &str,
|
||||
level_id: Option<&str>,
|
||||
incomplete_message: &str,
|
||||
) -> Result<(), String> {
|
||||
match (job_id, worker_id, lease_token) {
|
||||
(Some(job_id), Some(worker_id), Some(lease_token)) => {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
job_id,
|
||||
worker_id,
|
||||
lease_token,
|
||||
expected_job_kinds,
|
||||
session_id,
|
||||
owner_user_id,
|
||||
level_id,
|
||||
)
|
||||
}
|
||||
(None, None, None) => Ok(()),
|
||||
_ => Err(incomplete_message.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_puzzle_form_draft_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleFormDraftSaveInput,
|
||||
@@ -1316,11 +1338,11 @@ fn save_puzzle_generated_images_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleGeneratedImagesSaveInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[
|
||||
PUZZLE_COMPILE_DRAFT_JOB_KIND,
|
||||
PUZZLE_GENERATE_IMAGES_JOB_KIND,
|
||||
@@ -1328,6 +1350,7 @@ fn save_puzzle_generated_images_tx(
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
"拼图图片保存外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
@@ -1413,15 +1436,16 @@ fn save_puzzle_ui_background_tx(
|
||||
ctx: &TxContext,
|
||||
input: PuzzleUiBackgroundSaveInput,
|
||||
) -> Result<PuzzleAgentSessionSnapshot, String> {
|
||||
validate_puzzle_external_generation_write_guard(
|
||||
validate_optional_puzzle_external_generation_write_guard(
|
||||
ctx,
|
||||
&input.external_generation_job_id,
|
||||
&input.external_generation_worker_id,
|
||||
&input.external_generation_lease_token,
|
||||
input.external_generation_job_id.as_deref(),
|
||||
input.external_generation_worker_id.as_deref(),
|
||||
input.external_generation_lease_token.as_deref(),
|
||||
&[PUZZLE_GENERATE_UI_BACKGROUND_JOB_KIND],
|
||||
&input.session_id,
|
||||
&input.owner_user_id,
|
||||
input.level_id.as_deref(),
|
||||
"拼图 UI 背景保存外部生成 guard 不完整",
|
||||
)?;
|
||||
let row = get_owned_session_row(ctx, &input.session_id, &input.owner_user_id)?;
|
||||
let mut draft = deserialize_draft_required(&row.draft_json)?;
|
||||
|
||||
Reference in New Issue
Block a user