Commit b69415c663f207a470ed98149fb6dfc724dead67
1 parent
44dd52c2
api.py 任务记录改成落盘,不再随进程重启清空
worker()/create() 每次状态变化都写一份到 sqlite(四张卡各一个独立文件), 进程启动时读回内存;in-flight 的 queued/running 记录重启后没法恢复,改判为 failed 并写清原因,不再卡成一条永远"运行中"但没人会再碰的死记录。 落地前用不依赖 torch/GPU 的独立单测验证了 done 保留/running→failed 改判/ upsert 覆盖不重复,再按"只重启空闲的那张卡"逐张滚动重启四个后端上线, 用真实任务验证过重启后 api.py 自己(不经过 console 那层兜底)能找回完整记录。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
3 changed files
with
93 additions
and
2 deletions
CHANGELOG.md
| 1 | # 变更记录 | 1 | # 变更记录 |
| 2 | 2 | ||
| 3 | +## 2026-08-20(夜,又续二)— api.py 任务记录改成落盘,不再随进程重启清空 | ||
| 4 | + | ||
| 5 | +**背景**:`h3-console` 的弹性调度器会常规性地 kill+restart `api.py` 进程来切 fl2va/ref2va 分区, | ||
| 6 | +而 `JOBS: dict[str, Job] = {}` 只在内存里——`API.md` 早就写明"进程重启就丢",但那是在 | ||
| 7 | +"重启是稀有手动事件"的前提下写的,弹性调度器把这条几乎不会撞上的边缘情况变成了每天发生 | ||
| 8 | +好几次的常规操作,之前排查"下载/播放好像有问题"时才真的踩上(详见上文 h3-console 相关章节)。 | ||
| 9 | + | ||
| 10 | +**结果**:`worker()` 和 `create()` 里每次任务状态变化都往 sqlite 写一份(四张卡各一个独立文件 | ||
| 11 | +`jobs_gpu{N}.sqlite3`,不共享一份、不用处理跨进程写锁竞争),进程启动时读回内存。落盘时正好是 | ||
| 12 | +queued/running 的记录一律改判为 failed 并写清原因——in-flight 状态没法恢复(GPU 上下文和排队 | ||
| 13 | +状态本来就不落盘),让它继续显示"运行中"只会更误导人。 | ||
| 14 | + | ||
| 15 | +落地前先写了一个不依赖 torch/GPU 的独立测试(`Job`/`_persist`/`_load_jobs` 抽出来单测: | ||
| 16 | +done 保留、running→failed 改判、upsert 覆盖不重复,三点全过),再照「只重启空闲的那张卡」的 | ||
| 17 | +纪律逐张滚动重启四个后端(8011 当时正跑着别人提交的真实任务,等它跑完才重启,没打断)。 | ||
| 18 | +验证:提交任务→跑完→重启该后端→**直接问 `api.py` 自己**(不经过 console 那层兜底)确认状态、 | ||
| 19 | +文件路径、`peak_vram_gb` 等全部还在,下载也正常拿到完整文件。 | ||
| 20 | + | ||
| 21 | +h3-console 那边此前加的"后端忘了就读磁盘兜底"逻辑继续保留,不冲突——根因修了之后它退化成 | ||
| 22 | +真正罕见崩溃场景(不是弹性调度触发的)下的第二道保险,不是唯一防线。 | ||
| 23 | + | ||
| 24 | +--- | ||
| 25 | + | ||
| 26 | +## 2026-08-20(夜,又续)— 左下角后端状态行补上 running/queued,不再看着像空闲 | ||
| 27 | + | ||
| 28 | +**背景**:用户发现汇总行显示"运行中 1",但下面四行每张卡都只写 `[partition] ok`, | ||
| 29 | +看不出到底是哪张卡在跑。查了 `gateway.py`/`api.py` 的 `/health`:`status` 字段只是模型 | ||
| 30 | +健康态(ok/down,跟负载无关),真正的忙闲要看同一个对象上的 `running`/`queued` 两个计数字段—— | ||
| 31 | +这两个字段数据其实早就在 `/v1/backends` 的响应里了,只是 `static/index.html` 的 | ||
| 32 | +`refreshBackends()` 拼字符串时压根没读它们,纯粹是前端漏显示,不是后端缺数据。 | ||
| 33 | + | ||
| 34 | +**结果**:那一行改成 `${status}${running>0 ? ' running×N' : queued>0 ? ' queued×N' : ''}`。 | ||
| 35 | +验证:当前 `8011 gpu2 [fl2va]` 真的在跑一个任务,`/v1/backends` 里它的 `running:1`, | ||
| 36 | +页面现在显示 `ok running×1`,其余三张空闲卡仍是纯 `ok`。纯静态文件改动,`cat >` 覆盖即生效, | ||
| 37 | +没碰 `server.py`,不需要重启 console 进程。 | ||
| 38 | + | ||
| 39 | +--- | ||
| 40 | + | ||
| 3 | ## 2026-08-20(夜,再续)— h3-console 任务列表加删除/回收站 | 41 | ## 2026-08-20(夜,再续)— h3-console 任务列表加删除/回收站 |
| 4 | 42 | ||
| 5 | **结果**:任务列表每行右侧加「⋯」菜单(含「删除」),左下角加「🗑 回收站」入口,点开是删除任务的 | 43 | **结果**:任务列表每行右侧加「⋯」菜单(含「删除」),左下角加「🗑 回收站」入口,点开是删除任务的 |
h3-console/static/index.html
| @@ -620,7 +620,13 @@ async function refreshBackends() { | @@ -620,7 +620,13 @@ async function refreshBackends() { | ||
| 620 | const r = await fetch('/v1/backends'); const d = await r.json(); | 620 | const r = await fetch('/v1/backends'); const d = await r.json(); |
| 621 | document.getElementById('backends').textContent = | 621 | document.getElementById('backends').textContent = |
| 622 | `后端: ${d.ready}/${d.total} ready · 排队 ${d.queued} · 运行中 ${d.running}\n` + | 622 | `后端: ${d.ready}/${d.total} ready · 排队 ${d.queued} · 运行中 ${d.running}\n` + |
| 623 | - d.backends.map(b => `${b.backend.replace('http://127.0.0.1:','')} gpu${b.gpu} [${b.partition||'?'}] ${b.status}`).join('\n'); | 623 | + d.backends.map(b => { |
| 624 | + // b.status 只是模型健康态(ok/down),不代表这张卡是否正在跑任务—— | ||
| 625 | + // running/queued 是 api.py /health 里单独的计数字段,之前没往界面上带, | ||
| 626 | + // 导致明明有任务在跑却只显示 "ok",看着像空闲。 | ||
| 627 | + const busy = b.running > 0 ? ` running×${b.running}` : (b.queued > 0 ? ` queued×${b.queued}` : ''); | ||
| 628 | + return `${b.backend.replace('http://127.0.0.1:','')} gpu${b.gpu} [${b.partition||'?'}] ${b.status}${busy}`; | ||
| 629 | + }).join('\n'); | ||
| 624 | } catch (e) {} | 630 | } catch (e) {} |
| 625 | } | 631 | } |
| 626 | 632 |
minimax-h3/api.py
| @@ -6,7 +6,7 @@ | @@ -6,7 +6,7 @@ | ||
| 6 | 为什么是串行单 worker:一条生成占 ~12.3G 显存 + ~32G CPU 内存, | 6 | 为什么是串行单 worker:一条生成占 ~12.3G 显存 + ~32G CPU 内存, |
| 7 | 一张 22G 卡同时只能跑一条。并发提交会排队,不会 OOM。 | 7 | 一张 22G 卡同时只能跑一条。并发提交会排队,不会 OOM。 |
| 8 | """ | 8 | """ |
| 9 | -import os, sys, time, uuid, threading, queue, traceback, io, urllib.request | 9 | +import os, sys, time, uuid, threading, queue, traceback, io, sqlite3, urllib.request |
| 10 | from typing import Optional, Literal | 10 | from typing import Optional, Literal |
| 11 | from pathlib import Path | 11 | from pathlib import Path |
| 12 | 12 | ||
| @@ -93,6 +93,50 @@ Q: "queue.Queue[str]" = queue.Queue() | @@ -93,6 +93,50 @@ Q: "queue.Queue[str]" = queue.Queue() | ||
| 93 | LOCK = threading.Lock() | 93 | LOCK = threading.Lock() |
| 94 | STATE = {"model": "loading", "loaded_at": None, "gpu": os.environ.get("CUDA_VISIBLE_DEVICES", "?")} | 94 | STATE = {"model": "loading", "loaded_at": None, "gpu": os.environ.get("CUDA_VISIBLE_DEVICES", "?")} |
| 95 | 95 | ||
| 96 | +# ---------------- 任务记录落盘 ---------------- | ||
| 97 | +# 之前 JOBS 只在内存里,进程重启(比如 h3-console 的弹性调度器切分区)就把这张表清空—— | ||
| 98 | +# 视频文件在 outputs/ 没事,但 id→文件路径这层映射没了,前端/API 都查不到。改成每次状态变化 | ||
| 99 | +# 都往 sqlite 写一份,进程起来时先读回内存。四个 GPU 各一个进程,按 GPU id 分开的文件, | ||
| 100 | +# 不共享一份——共享一份要处理多进程并发写 sqlite 的锁竞争,没必要:反正一个进程只会在同一张 | ||
| 101 | +# GPU 上起来,per-GPU 的文件天然不会有另一个进程来抢。 | ||
| 102 | +JOBS_DB = BASE / f"jobs_gpu{os.environ.get('CUDA_VISIBLE_DEVICES', 'x')}.sqlite3" | ||
| 103 | +_JOBS_CON = sqlite3.connect(JOBS_DB, check_same_thread=False) | ||
| 104 | +_JOBS_CON.execute( | ||
| 105 | + "CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, updated_at REAL NOT NULL, data TEXT NOT NULL)" | ||
| 106 | +) | ||
| 107 | +_JOBS_CON.commit() | ||
| 108 | + | ||
| 109 | +def _persist(job: Job): | ||
| 110 | + with LOCK: | ||
| 111 | + _JOBS_CON.execute( | ||
| 112 | + "INSERT INTO jobs (id, updated_at, data) VALUES (?,?,?) " | ||
| 113 | + "ON CONFLICT(id) DO UPDATE SET updated_at=excluded.updated_at, data=excluded.data", | ||
| 114 | + (job.id, time.time(), job.model_dump_json()), | ||
| 115 | + ) | ||
| 116 | + _JOBS_CON.commit() | ||
| 117 | + | ||
| 118 | +def _load_jobs(): | ||
| 119 | + """进程刚起来时把上次落盘的任务记录读回内存。in-flight 的状态没法恢复——Q 是空的, | ||
| 120 | + GPU 上下文也是全新的,没有任何东西能"接着跑"。落盘时正好是 queued/running 的一律 | ||
| 121 | + 改判为 failed 并写清原因,不然它会卡成一条永远显示"运行中"但没有 worker 会再碰的 | ||
| 122 | + 死记录,比直接查不到这个 id 更容易让人误判成"卡住了,再等等"。""" | ||
| 123 | + n = 0 | ||
| 124 | + for (data,) in _JOBS_CON.execute("SELECT data FROM jobs"): | ||
| 125 | + try: | ||
| 126 | + j = Job.model_validate_json(data) | ||
| 127 | + except Exception: | ||
| 128 | + continue # 单条记录损坏不该挡住整个服务启动 | ||
| 129 | + if j.status in ("queued", "running"): | ||
| 130 | + j.status = "failed" | ||
| 131 | + j.error = "进程重启导致这条任务的执行状态丢失(GPU 上下文和排队状态不落盘,无法恢复)" | ||
| 132 | + j.finished_at = j.finished_at or time.time() | ||
| 133 | + JOBS[j.id] = j | ||
| 134 | + n += 1 | ||
| 135 | + if n: | ||
| 136 | + print(f"[api] 从磁盘恢复了 {n} 条历史任务记录({JOBS_DB.name})", flush=True) | ||
| 137 | + | ||
| 138 | +_load_jobs() | ||
| 139 | + | ||
| 96 | # ---------------- 推理 worker ---------------- | 140 | # ---------------- 推理 worker ---------------- |
| 97 | # DiT、processor、Turbo LoRA 三样成套,跨分区混用会对不上层。 | 141 | # DiT、processor、Turbo LoRA 三样成套,跨分区混用会对不上层。 |
| 98 | # ref2v 的 Turbo LoRA 只有 v0.1,fl2v 那个是 v1.0 768p——这一项两边成色不同。 | 142 | # ref2v 的 Turbo LoRA 只有 v0.1,fl2v 那个是 v1.0 768p——这一项两边成色不同。 |
| @@ -174,6 +218,7 @@ def worker(): | @@ -174,6 +218,7 @@ def worker(): | ||
| 174 | try: | 218 | try: |
| 175 | j.status = "running"; j.started_at = time.time() | 219 | j.status = "running"; j.started_at = time.time() |
| 176 | j.total_steps = j.req.steps | 220 | j.total_steps = j.req.steps |
| 221 | + _persist(j) | ||
| 177 | torch.cuda.reset_peak_memory_stats() | 222 | torch.cuda.reset_peak_memory_stats() |
| 178 | fs = j.req.flow_shift if j.req.flow_shift is not None else (6.0 if j.req.turbo else 12.0) | 223 | fs = j.req.flow_shift if j.req.flow_shift is not None else (6.0 if j.req.turbo else 12.0) |
| 179 | if not j.req.turbo: | 224 | if not j.req.turbo: |
| @@ -203,6 +248,7 @@ def worker(): | @@ -203,6 +248,7 @@ def worker(): | ||
| 203 | traceback.print_exc() | 248 | traceback.print_exc() |
| 204 | finally: | 249 | finally: |
| 205 | j.finished_at = time.time() | 250 | j.finished_at = time.time() |
| 251 | + _persist(j) | ||
| 206 | if not j.req.turbo: # 下一个任务可能要 turbo,把 LoRA 挂回去 | 252 | if not j.req.turbo: # 下一个任务可能要 turbo,把 LoRA 挂回去 |
| 207 | try: | 253 | try: |
| 208 | pipe.load_lora(pipe.dit, _turbo_lora_config()) | 254 | pipe.load_lora(pipe.dit, _turbo_lora_config()) |
| @@ -231,6 +277,7 @@ def create(req: GenReq): | @@ -231,6 +277,7 @@ def create(req: GenReq): | ||
| 231 | jid = uuid.uuid4().hex[:12] | 277 | jid = uuid.uuid4().hex[:12] |
| 232 | with LOCK: | 278 | with LOCK: |
| 233 | JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time()) | 279 | JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time()) |
| 280 | + _persist(JOBS[jid]) # LOCK 外面调用 —— _persist 自己会拿 LOCK,Lock 不可重入,嵌套会死锁 | ||
| 234 | Q.put(jid) | 281 | Q.put(jid) |
| 235 | # 耗时的自变量是序列行数(潜帧数 x 宽 x 高 / 1024),不是分辨率——分辨率和帧数是同一笔预算。 | 282 | # 耗时的自变量是序列行数(潜帧数 x 宽 x 高 / 1024),不是分辨率——分辨率和帧数是同一笔预算。 |
| 236 | # 基准点 832x480x124f = 14430 行,4 步实测 692 秒 = 4 x 132 + 163 | 283 | # 基准点 832x480x124f = 14430 行,4 步实测 692 秒 = 4 x 132 + 163 |