Commit 02a8e7050b8b2b719124202b6e067be280c62dbd

Authored by yibo
1 parent 588934a8

修复 console 任务提交竞态 + 任务详情页加显式时长字段

_try_submit 在 gateway_id 还是 NULL 的窗口里可能被 create_video/get_video/
reconciler_loop 并发调用,导致同一个任务被提交给 gateway 两次,两张卡各烧
一遍 GPU、其中一条变成没人追踪的孤儿任务。加进程内 _SUBMITTING 集合堵住这
个竞态窗口。

任务详情页只显示帧数不显示秒数,加一行按 24fps 折算的"时长"。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CHANGELOG.md
1 # 变更记录 1 # 变更记录
2 2
  3 +## 2026-08-20(夜,又续五)— 832×480 高分辨率 5s/10s/15s 实测 + 揪出一个真实并发 bug
  4 +
  5 +**背景**:用户想知道高分辨率(832×480,线上标准档)配 fl2va 首尾帧条件下,5s/10s/15s 三档能不能跑。
  6 +之前「15 秒时长实测」那节测的是纯文生视频(无关键帧),这次是同一个真实任务
  7 +(`ed3663dcda7f`)的 prompt + 首尾帧图,换到 832×480,按用户要求通过 console(8080)正式提交、
  8 +可追溯,而不是绕开它直接打 api.py。
  9 +
  10 +### 实测结果
  11 +
  12 +| 时长 | 帧数 | rows | 结果 | 峰值显存 | 耗时 |
  13 +|---|---|---|---|---|---|
  14 +| 5s | 124 | 14430 | ✅ 正常 | 12.95G | 649s |
  15 +| 10s | 243 | 28080 | ❌ OOM | 撞到 ~20.3G 才崩 | 26s 后失败 |
  16 +| 15s | 362 | 41730 | ❌ OOM | 撞到 ~19G 才崩 | 24s 后失败 |
  17 +
  18 +**结论**:832×480 加了首尾帧条件后,可行边界和纯文生视频时的判断一致——只有 5s(124帧) 稳,
  19 +10s/15s 都撞显存墙。fl2va 的关键帧编码本身没有显著推高显存(12.95G vs 纯文生视频基准 12.2-12.3G),
  20 +真正的墙还是 rows(分辨率×帧数),`api.py` 现在的 `ROWS_LIMIT=20000` 挡住 10s/15s 这两档是对的,
  21 +不是误伤。
  22 +
  23 +为了做这组实测,`api.py` 的 `ROWS_LIMIT` 改成可被环境变量 `H3_ROWS_LIMIT` 覆盖(默认值仍是
  24 +20000,不影响线上),测试期间临时把 GPU1 从 ref2va 切成 fl2va、GPU1/GPU2 都带上放宽后的限制,
  25 +凑够三个 fl2va 槽位并发测三档;测完已经把 GPU1 切回 ref2va、GPU2/GPU3 的限制改回默认值,
  26 +四卡恢复到 GPU0/1=ref2va、GPU2/3=fl2va 的标准基线,`ready:4/4` 验证过。
  27 +
  28 +### 顺带揪出的真实 bug:`_try_submit` 的并发提交竞态
  29 +
  30 +测 15s 那组时,`console` 的任务详情页显示后端是 8011,但我提交时 gateway 明明说选的是 8010——
  31 +查下来是同一条任务被**提交给 gateway 两次**,两张卡上各生成了一条独立记录、各自跑了一遍、
  32 +各自 OOM 了一次。根因:`create_video()` 首次提交、`get_video()` 轮询时顺手提交、
  33 +`reconciler_loop()` 每 5 秒扫一遍,三条路都会在 `gateway_id` 还是 `NULL` 的窗口里调
  34 +`_try_submit(cid)`;`await client.post(...)` 让出控制权的那段时间里,另外两条路完全可能也读到
  35 +"还没提交过",于是并发发出第二个 POST。console 只记得住最后一次 `UPDATE` 写回的 `gateway_id`,
  36 +另一条产物变成孤儿任务——真实存在、真烧了一遍 GPU,但没人知道它在跑、也没人能停它。
  37 +这次是因为我自己写的后台轮询脚本在提交后几乎立刻开始高频 poll,把这个本来就存在的竞态窗口
  38 +撞开了,不是新引入的问题。
  39 +
  40 +**修法**:`server.py` 加一个进程内的 `_SUBMITTING: set[str]`,`_try_submit` 一开始检查
  41 +`cid in _SUBMITTING`,通过后立刻 `add`、结束时 `finally` 里 `discard`——两行判断中间没有
  42 +`await`,在单进程单事件循环里天然原子,足够堵住这个窗口,不需要引入数据库锁。
  43 +已重启两个 console 进程(8080 带弹性调度器、8081 子路径那个)验证生效。
  44 +
  45 +### 顺带修的小问题:任务详情页没有显式"时长"字段
  46 +
  47 +只显示帧数(比如"362帧"),用户得自己心算 362/24≈15 秒才知道对应哪一档。在"分辨率/帧数"和
  48 +"Turbo/步数"之间加了一行"时长",按 24fps 折算成秒数直接显示。纯前端展示改动,`req.frames`
  49 +本来就在返回的数据里,不是数据缺失,是没往界面上摆。
  50 +
  51 +---
  52 +
3 ## 2026-08-20(夜,又续四)— h3-console 公网访问改走 qwen.wudaima.com/console 子路径 53 ## 2026-08-20(夜,又续四)— h3-console 公网访问改走 qwen.wudaima.com/console 子路径
4 54
5 **背景**:`gateway.wudaima.com` 那条独立域名线路确认走不通(缺口在 60.205.214.169 那台 frp 55 **背景**:`gateway.wudaima.com` 那条独立域名线路确认走不通(缺口在 60.205.214.169 那台 frp
h3-console/server.py
@@ -154,37 +154,54 @@ def _row_view(row) -&gt; dict: @@ -154,37 +154,54 @@ def _row_view(row) -&gt; dict:
154 return view 154 return view
155 155
156 156
  157 +# create_video 首次提交、get_video 轮询时顺手提交、reconciler_loop 每 5 秒扫描,三条路都会在
  158 +# gateway_id 还是 NULL 的窗口里调 _try_submit(cid)。三个协程共享同一个事件循环,`await
  159 +# client.post(...)` 让出控制权的那段时间里,另外两条路完全可能也读到「还没提交过」,
  160 +# 于是同一个 cid 被 POST 给 gateway 两次——两张卡各烧一遍 GPU,console 只记得住最后一次
  161 +# UPDATE 写回的 gateway_id,另一条产物是真实存在但没人知道、也没人能停的孤儿任务。
  162 +# 2026-08-20 实测踩到过:一个 15s/832×480 的探测任务真的在两张卡上各 OOM 了一次。
  163 +# 修法:进程内占坑(不是数据库锁——server.py 单进程单事件循环,内存里一个 set 就够,
  164 +# 两行判断中间没有 await,天然原子,不会被其它协程插进来)。
  165 +_SUBMITTING: set[str] = set()
  166 +
  167 +
157 async def _try_submit(cid: str): 168 async def _try_submit(cid: str):
158 """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。""" 169 """把一条本地排队的任务尝试提交给 gateway。给 create_video 首次调用和 reconciler_loop 复用。"""
  170 + if cid in _SUBMITTING:
  171 + return # 已经有一次提交在飞行中,等它写完 gateway_id 再说,不要并发再发一次
159 with db() as con: 172 with db() as con:
160 row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone() 173 row = con.execute("SELECT * FROM jobs WHERE id=?", (cid,)).fetchone()
161 if row is None or row["gateway_id"] is not None: 174 if row is None or row["gateway_id"] is not None:
162 return # 任务不存在,或已经提交过 175 return # 任务不存在,或已经提交过
163 - req = json.loads(row["req_json"]) 176 + _SUBMITTING.add(cid)
164 try: 177 try:
165 - resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req)  
166 - except httpx.HTTPError:  
167 - return # gateway 暂时连不上,下一轮再试  
168 - now = time.time()  
169 - if resp.status_code < 400:  
170 - job = resp.json()  
171 - gateway_id = job["id"]  
172 - job["id"] = cid # 对外只暴露 console 自己的 id  
173 - with db() as con:  
174 - con.execute(  
175 - "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?",  
176 - (gateway_id, job.get("backend"), job.get("status", "queued"),  
177 - now, json.dumps(job, ensure_ascii=False), cid),  
178 - )  
179 - elif resp.status_code in (503, 429):  
180 - return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试  
181 - else:  
182 - # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败  
183 - err_view = {"id": cid, "status": "failed", "error": resp.text,  
184 - "req": req, "partition": row["partition"], "created_at": row["created_at"]}  
185 - with db() as con:  
186 - con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?",  
187 - (now, json.dumps(err_view, ensure_ascii=False), cid)) 178 + req = json.loads(row["req_json"])
  179 + try:
  180 + resp = await client.post(f"{GATEWAY_URL}/v1/videos", json=req)
  181 + except httpx.HTTPError:
  182 + return # gateway 暂时连不上,下一轮再试
  183 + now = time.time()
  184 + if resp.status_code < 400:
  185 + job = resp.json()
  186 + gateway_id = job["id"]
  187 + job["id"] = cid # 对外只暴露 console 自己的 id
  188 + with db() as con:
  189 + con.execute(
  190 + "UPDATE jobs SET gateway_id=?, backend=?, status=?, updated_at=?, last_known_json=? WHERE id=?",
  191 + (gateway_id, job.get("backend"), job.get("status", "queued"),
  192 + now, json.dumps(job, ensure_ascii=False), cid),
  193 + )
  194 + elif resp.status_code in (503, 429):
  195 + return # 没有就绪后端 / 队列满,留在本地队列,等 flip 或别的任务跑完再试
  196 + else:
  197 + # 真正的客户端错误(比如 422 参数越界、400 turbo+steps>8),重试也不会成功,直接判失败
  198 + err_view = {"id": cid, "status": "failed", "error": resp.text,
  199 + "req": req, "partition": row["partition"], "created_at": row["created_at"]}
  200 + with db() as con:
  201 + con.execute("UPDATE jobs SET status='failed', updated_at=?, last_known_json=? WHERE id=?",
  202 + (now, json.dumps(err_view, ensure_ascii=False), cid))
  203 + finally:
  204 + _SUBMITTING.discard(cid)
188 205
189 206
190 async def _refresh_from_gateway(cid: str, gateway_id: str): 207 async def _refresh_from_gateway(cid: str, gateway_id: str):
h3-console/static/index.html
@@ -593,6 +593,7 @@ async function refreshDetail() { @@ -593,6 +593,7 @@ async function refreshDetail() {
593 <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'&lt;')}</dd> 593 <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'&lt;')}</dd>
594 <dt>分区</dt><dd>${j.partition || '—'}</dd> 594 <dt>分区</dt><dd>${j.partition || '—'}</dd>
595 <dt>分辨率/帧数</dt><dd>${req.width || '?'}×${req.height || '?'} × ${req.frames || '?'}帧</dd> 595 <dt>分辨率/帧数</dt><dd>${req.width || '?'}×${req.height || '?'} × ${req.frames || '?'}帧</dd>
  596 + <dt>时长</dt><dd>${req.frames ? (req.frames / 24).toFixed(1) + ' 秒(按 24fps 折算)' : '?'}</dd>
596 <dt>Turbo/步数</dt><dd>${req.turbo === false ? '关' : '开'} / ${req.steps ?? '?'} 步,seed=${req.seed ?? 0}</dd> 597 <dt>Turbo/步数</dt><dd>${req.turbo === false ? '关' : '开'} / ${req.steps ?? '?'} 步,seed=${req.seed ?? 0}</dd>
597 <dt>后端</dt><dd>${j.backend || '(尚未分配)'}</dd> 598 <dt>后端</dt><dd>${j.backend || '(尚未分配)'}</dd>
598 <dt>提交时间</dt><dd>${fmtTime(j.created_at)}</dd> 599 <dt>提交时间</dt><dd>${fmtTime(j.created_at)}</dd>