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 ## 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: &quot;queue.Queue[str]&quot; = queue.Queue() @@ -93,6 +93,50 @@ Q: &quot;queue.Queue[str]&quot; = 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