Commit 02a8e7050b8b2b719124202b6e067be280c62dbd
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>
Showing
3 changed files
with
92 additions
and
24 deletions
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) -> dict: | @@ -154,37 +154,54 @@ def _row_view(row) -> 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,'<')}</dd> | 593 | <dt>Prompt</dt><dd>${(req.prompt || '').replace(/</g,'<')}</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> |