Commit 16dffe0a5210969251715dabdb0abb163ea26b17

Authored by yibo
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
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&quot;ws://{COMFY_HOST}:{COMFY_PORT}/ws&quot;
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&quot;ws://{COMFY_HOST}:{COMFY_PORT}/ws&quot;
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  
... ...