Commit b69415c663f207a470ed98149fb6dfc724dead67

Authored by yibo
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>
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 41 ## 2026-08-20(夜,再续)— h3-console 任务列表加删除/回收站
4 42  
5 43 **结果**:任务列表每行右侧加「⋯」菜单(含「删除」),左下角加「🗑 回收站」入口,点开是删除任务的
... ...
h3-console/static/index.html
... ... @@ -620,7 +620,13 @@ async function refreshBackends() {
620 620 const r = await fetch('/v1/backends'); const d = await r.json();
621 621 document.getElementById('backends').textContent =
622 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 630 } catch (e) {}
625 631 }
626 632  
... ...
minimax-h3/api.py
... ... @@ -6,7 +6,7 @@
6 6 为什么是串行单 worker:一条生成占 ~12.3G 显存 + ~32G CPU 内存,
7 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 10 from typing import Optional, Literal
11 11 from pathlib import Path
12 12  
... ... @@ -93,6 +93,50 @@ Q: &quot;queue.Queue[str]&quot; = queue.Queue()
93 93 LOCK = threading.Lock()
94 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 140 # ---------------- 推理 worker ----------------
97 141 # DiT、processor、Turbo LoRA 三样成套,跨分区混用会对不上层。
98 142 # ref2v 的 Turbo LoRA 只有 v0.1,fl2v 那个是 v1.0 768p——这一项两边成色不同。
... ... @@ -174,6 +218,7 @@ def worker():
174 218 try:
175 219 j.status = "running"; j.started_at = time.time()
176 220 j.total_steps = j.req.steps
  221 + _persist(j)
177 222 torch.cuda.reset_peak_memory_stats()
178 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 224 if not j.req.turbo:
... ... @@ -203,6 +248,7 @@ def worker():
203 248 traceback.print_exc()
204 249 finally:
205 250 j.finished_at = time.time()
  251 + _persist(j)
206 252 if not j.req.turbo: # 下一个任务可能要 turbo,把 LoRA 挂回去
207 253 try:
208 254 pipe.load_lora(pipe.dit, _turbo_lora_config())
... ... @@ -231,6 +277,7 @@ def create(req: GenReq):
231 277 jid = uuid.uuid4().hex[:12]
232 278 with LOCK:
233 279 JOBS[jid] = Job(id=jid, status="queued", req=req, created_at=time.time())
  280 + _persist(JOBS[jid]) # LOCK 外面调用 —— _persist 自己会拿 LOCK,Lock 不可重入,嵌套会死锁
234 281 Q.put(jid)
235 282 # 耗时的自变量是序列行数(潜帧数 x 宽 x 高 / 1024),不是分辨率——分辨率和帧数是同一笔预算。
236 283 # 基准点 832x480x124f = 14430 行,4 步实测 692 秒 = 4 x 132 + 163
... ...