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