Commit 16dffe0a5210969251715dabdb0abb163ea26b17
1 parent
4c014c9f
raylight: 重启不再让排队/运行中的任务永久失败
_load_jobs() 里 status=='running' 的任务之前直接判 failed("无法恢复")。实测踩坑: 等当前任务跑完再重启,下一条排队任务在 worker 零延迟捞起的瞬间被判死——请求参数本身 完好,跟手动点"重跑"没区别,没理由不能自动重试。改成退回 queued 重新跑, Job.restart_recoveries 封顶 2 次,避免毒药请求无限重试拖垮队列。 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SBpJTe8WfuYkKwjMaRSDg6
Showing
3 changed files
with
80 additions
and
10 deletions
raylight/notes.md
| ... | ... | @@ -484,3 +484,29 @@ capacity of 21.48 GiB of which 262.25 MiB is free. ... this process has 20.26 Gi |
| 484 | 484 | 所有真实OOM都发生在GPU0(跟之前的结论一致:GPU0同时扛文本编码器+DiT分片+这块的attention |
| 485 | 485 | buffer,是四张卡里最先顶到天花板的一张),而且都在提交后30秒内就报错——说明失败发生在采样 |
| 486 | 486 | 刚开始分配显存的时候,不是跑到一半才炸,比较干脆,不存在"跑了很久才失败"的浪费。 |
| 487 | + | |
| 488 | +## 2026-09-09 — `/health`排队计数修正 + 重启不再让排在后面的任务永久失败 | |
| 489 | + | |
| 490 | +**背景**:`/health`的`queued`字段一直用`Q.qsize()`——取消一条排队中的任务只是把`JOBS`里 | |
| 491 | +那条的`status`标成`cancelled`,不会把它从`Q`摘除,`worker()`要等真正`Q.get()`到它才跳过 | |
| 492 | +丢弃。堆量取消场景下`qsize()`会把这些还没被捞到的"僵尸"entry也算进去,实测显示"排队253", | |
| 493 | +但直接读`jobs.sqlite3`统计真实`status=='queued'`只有137条,差了115条。改成跟`running`同 | |
| 494 | +口径,直接数`JOBS`里`status=='queued'`的条目。 | |
| 495 | + | |
| 496 | +**推翻的假设**:`_load_jobs()`里`status=='running'`的任务之前直接判`failed`,注释写死 | |
| 497 | +"无法恢复"。**这次实际踩了一次坑才发现这个假设站不住**:为了不打断当前正在跑的任务,等它 | |
| 498 | +跑完再重启——但下一条排队任务在当前任务跑完的瞬间就被`worker()`零延迟捞起转成`running`, | |
| 499 | +这个时间窗口比manual操作的反应速度短得多,重启命令和"抢下一条任务"几乎同时发生,结果新 | |
| 500 | +running的这条被这段逻辑直接判死。**"无法恢复"这个结论本身就是错的**——请求的原始参数 | |
| 501 | +(`req`)完好无损,跟用户手动点"重跑"没有本质区别,没理由不能自动退回`queued`重跑一遍。 | |
| 502 | + | |
| 503 | +**修复**:`status=='running'`时改成自动退回`queued`(清空`started_at`/`step`/`total_steps`/ | |
| 504 | +`comfy_prompt_id`等本次执行的残留状态,保留原`queue_seq`不打乱排队顺序),新增`Job. | |
| 505 | +restart_recoveries`计数,封顶`MAX_RESTART_RECOVERIES=2`——超过上限才老实判`failed`,避免 | |
| 506 | +某条请求本身有问题(比如参数组合注定OOM)每次一跑就把进程带崩,陷入"重启→判running→ | |
| 507 | +退回queued→再跑崩"的无限循环拖垮整条队列。 | |
| 508 | + | |
| 509 | +**没有做的事**:曾经考虑过更复杂的方案——重启前加一个"暂停"开关阻止worker摘取下一条排队 | |
| 510 | +任务,等到真正`running==0`再重启,这样能省掉被打断那条任务已经跑掉的GPU时间(不用整个 | |
| 511 | +重跑)。这次先没做,因为`restart_recoveries`已经能保证"不会永久失败"这个底线;真要再优化 | |
| 512 | +"重启不浪费已跑的算力",可以在这个基础上加暂停开关,属于锦上添花,不是这次的必需项。 | ... | ... |
raylight/scripts/server.py
| ... | ... | @@ -39,6 +39,7 @@ COMFY_WS = f"ws://{COMFY_HOST}:{COMFY_PORT}/ws" |
| 39 | 39 | # 没有这个字段就会一直认为任务"还没分配",播放/下载会被 409 拒绝——踩过这个坑,2026-08-22。 |
| 40 | 40 | SERVER_PORT = os.environ.get("PORT", "8100") |
| 41 | 41 | BACKEND_URL = f"http://127.0.0.1:{SERVER_PORT}" |
| 42 | +MAX_RESTART_RECOVERIES = 2 # 见 _load_jobs 里 status=='running' 分支的注释 | |
| 42 | 43 | |
| 43 | 44 | ROOT = Path("/data/models/raylight/ComfyUI") |
| 44 | 45 | INPUT_DIR = ROOT / "input" |
| ... | ... | @@ -166,6 +167,7 @@ class Job(BaseModel): |
| 166 | 167 | comfy_prompt_id: Optional[str] = None |
| 167 | 168 | backend: str = BACKEND_URL |
| 168 | 169 | queue_seq: int = 0 # 2026-09-03: 排队顺序+插队用,见 _queue_position/prioritize_video |
| 170 | + restart_recoveries: int = 0 # 2026-09-09: 见 _load_jobs 里 status=='running' 分支的注释 | |
| 169 | 171 | |
| 170 | 172 | |
| 171 | 173 | JOBS: dict[str, Job] = {} |
| ... | ... | @@ -230,11 +232,31 @@ def _load_jobs(): |
| 230 | 232 | _persist(j) |
| 231 | 233 | Q.put((j.queue_seq, j.id)) |
| 232 | 234 | elif j.status == "running": |
| 233 | - j.status = "failed" | |
| 234 | - j.error = "进程重启导致这条任务的执行状态丢失,无法恢复" | |
| 235 | - j.finished_at = time.time() | |
| 236 | - _persist(j) # 2026-08-23: 之前只改内存不落盘,sqlite里的status永远停在running/queued, | |
| 237 | - # 下次重启会被这段逻辑重新"发现"一遍、把人工写的详细错因覆盖回这句通用文案——必须写回去 | |
| 235 | + # 2026-09-09: 之前直接判failed,"无法恢复"——但这个结论只是"当前进度丢了",不代表 | |
| 236 | + # 请求本身有问题,跟用户手动点"重跑"没有本质区别。改成自动退回queued重新跑一遍, | |
| 237 | + # 不用等人工发现一条卡死的failed记录再手动重试(真实事故:等一条任务跑完再重启, | |
| 238 | + # 下一条排队任务在重启瞬间被worker捞起转成running,紧接着被这段逻辑判死)。 | |
| 239 | + # restart_recoveries封顶重试次数——防止某条请求本身有问题(比如注定OOM),每次 | |
| 240 | + # 一跑就把服务带崩,会陷入"重启->判running->退回queued->再跑崩"的无限循环, | |
| 241 | + # 拖慢/堵死整条队列;超过上限就老实判failed,等人工看一眼。 | |
| 242 | + if j.restart_recoveries < MAX_RESTART_RECOVERIES: | |
| 243 | + j.restart_recoveries += 1 | |
| 244 | + j.status = "queued" | |
| 245 | + j.started_at = None | |
| 246 | + j.step = 0 | |
| 247 | + j.total_steps = 0 | |
| 248 | + j.comfy_prompt_id = None | |
| 249 | + if j.queue_seq == 0: | |
| 250 | + j.queue_seq = int(j.created_at * 1e9) | |
| 251 | + _persist(j) | |
| 252 | + Q.put((j.queue_seq, j.id)) | |
| 253 | + else: | |
| 254 | + j.status = "failed" | |
| 255 | + j.error = (f"进程重启导致这条任务的执行状态丢失,已自动重试 " | |
| 256 | + f"{MAX_RESTART_RECOVERIES} 次仍未跑完,不再自动重试") | |
| 257 | + j.finished_at = time.time() | |
| 258 | + _persist(j) # 2026-08-23: 之前只改内存不落盘,sqlite里的status永远停在running/queued, | |
| 259 | + # 下次重启会被这段逻辑重新"发现"一遍、把人工写的详细错因覆盖回这句通用文案——必须写回去 | |
| 238 | 260 | JOBS[j.id] = j |
| 239 | 261 | conn.close() |
| 240 | 262 | ... | ... |
raylight/server.py
| ... | ... | @@ -39,6 +39,7 @@ COMFY_WS = f"ws://{COMFY_HOST}:{COMFY_PORT}/ws" |
| 39 | 39 | # 没有这个字段就会一直认为任务"还没分配",播放/下载会被 409 拒绝——踩过这个坑,2026-08-22。 |
| 40 | 40 | SERVER_PORT = os.environ.get("PORT", "8100") |
| 41 | 41 | BACKEND_URL = f"http://127.0.0.1:{SERVER_PORT}" |
| 42 | +MAX_RESTART_RECOVERIES = 2 # 见 _load_jobs 里 status=='running' 分支的注释 | |
| 42 | 43 | |
| 43 | 44 | ROOT = Path("/data/models/raylight/ComfyUI") |
| 44 | 45 | INPUT_DIR = ROOT / "input" |
| ... | ... | @@ -166,6 +167,7 @@ class Job(BaseModel): |
| 166 | 167 | comfy_prompt_id: Optional[str] = None |
| 167 | 168 | backend: str = BACKEND_URL |
| 168 | 169 | queue_seq: int = 0 # 2026-09-03: 排队顺序+插队用,见 _queue_position/prioritize_video |
| 170 | + restart_recoveries: int = 0 # 2026-09-09: 见 _load_jobs 里 status=='running' 分支的注释 | |
| 169 | 171 | |
| 170 | 172 | |
| 171 | 173 | JOBS: dict[str, Job] = {} |
| ... | ... | @@ -230,11 +232,31 @@ def _load_jobs(): |
| 230 | 232 | _persist(j) |
| 231 | 233 | Q.put((j.queue_seq, j.id)) |
| 232 | 234 | elif j.status == "running": |
| 233 | - j.status = "failed" | |
| 234 | - j.error = "进程重启导致这条任务的执行状态丢失,无法恢复" | |
| 235 | - j.finished_at = time.time() | |
| 236 | - _persist(j) # 2026-08-23: 之前只改内存不落盘,sqlite里的status永远停在running/queued, | |
| 237 | - # 下次重启会被这段逻辑重新"发现"一遍、把人工写的详细错因覆盖回这句通用文案——必须写回去 | |
| 235 | + # 2026-09-09: 之前直接判failed,"无法恢复"——但这个结论只是"当前进度丢了",不代表 | |
| 236 | + # 请求本身有问题,跟用户手动点"重跑"没有本质区别。改成自动退回queued重新跑一遍, | |
| 237 | + # 不用等人工发现一条卡死的failed记录再手动重试(真实事故:等一条任务跑完再重启, | |
| 238 | + # 下一条排队任务在重启瞬间被worker捞起转成running,紧接着被这段逻辑判死)。 | |
| 239 | + # restart_recoveries封顶重试次数——防止某条请求本身有问题(比如注定OOM),每次 | |
| 240 | + # 一跑就把服务带崩,会陷入"重启->判running->退回queued->再跑崩"的无限循环, | |
| 241 | + # 拖慢/堵死整条队列;超过上限就老实判failed,等人工看一眼。 | |
| 242 | + if j.restart_recoveries < MAX_RESTART_RECOVERIES: | |
| 243 | + j.restart_recoveries += 1 | |
| 244 | + j.status = "queued" | |
| 245 | + j.started_at = None | |
| 246 | + j.step = 0 | |
| 247 | + j.total_steps = 0 | |
| 248 | + j.comfy_prompt_id = None | |
| 249 | + if j.queue_seq == 0: | |
| 250 | + j.queue_seq = int(j.created_at * 1e9) | |
| 251 | + _persist(j) | |
| 252 | + Q.put((j.queue_seq, j.id)) | |
| 253 | + else: | |
| 254 | + j.status = "failed" | |
| 255 | + j.error = (f"进程重启导致这条任务的执行状态丢失,已自动重试 " | |
| 256 | + f"{MAX_RESTART_RECOVERIES} 次仍未跑完,不再自动重试") | |
| 257 | + j.finished_at = time.time() | |
| 258 | + _persist(j) # 2026-08-23: 之前只改内存不落盘,sqlite里的status永远停在running/queued, | |
| 259 | + # 下次重启会被这段逻辑重新"发现"一遍、把人工写的详细错因覆盖回这句通用文案——必须写回去 | |
| 238 | 260 | JOBS[j.id] = j |
| 239 | 261 | conn.close() |
| 240 | 262 | ... | ... |