Summary
A single job row left at status='running' in the DB blocks the entire queue for that gpu_ids group forever: processQueue treats any running row as "busy" and continues, so no further job for those GPUs is ever dispatched. This is reproducible, has happened repeatedly, and until now the only recovery was a manual DB/row cleanup.
Root cause (two compounding gaps)
-
Non-UI trainer types never write the DB. If a job is started with a trainer type that is not the UI-managed diffusion trainer (e.g. a CLI-style trainer), the python side never writes a terminal status. The worker then trusts python to do it: in ui/cron/actions/startJob.ts the exit path is effectively
if (code === 0) return; // assume python already set completed
so a clean exit (or a process that never ran the UI write path) leaves the row running forever.
-
No fallback reaper. The worker's exit listener is lost on worker restart; a process can also hang during shutdown after training finished (log goes silent, pid still alive); or the process dies (crash/OOM/reboot) without writing its own status. None of these states is ever re-examined, so the stuck row stays.
Both scenarios hit us on a 4-GPU FLUX setup: once, after a plain exit-0 run of a non-UI trainer type; and again after a worker restart. In both cases the queue for 0,1,2,3 never dispatched again until the row was marked manually.
Workaround (working, what we ship in production)
A reaper sweep at the top of processQueue (ui/cron/actions/processQueue.ts), run at most every 30 s:
const REAP_INTERVAL_MS = 30 * 1000;
const NO_PID_GRACE_MS = 5 * 60 * 1000; // 'running' w/o pid: launch grace
const LOG_SILENCE_KILL_MS = 10 * 60 * 1000; // live pid + log silent > 10 min => hung
let lastReapAt = 0;
const isReapPidAlive = (pid: number): boolean => {
try { process.kill(pid, 0); return true; }
catch (e: any) { return e?.code === 'EPERM'; }
};
export async function reapZombieJobs() {
const now = Date.now();
if (now - lastReapAt < REAP_INTERVAL_MS) return;
lastReapAt = now;
const stuck: Job[] = await prisma.job.findMany({ where: { status: 'running' } });
if (stuck.length === 0) return;
let trainingRoot: string | null = null;
for (const job of stuck) {
try {
if (job.pid != null && isReapPidAlive(job.pid)) {
if (trainingRoot === null) trainingRoot = await getTrainingFolder();
const logPath = path.join(trainingRoot, job.name, 'log.txt');
let silent = false;
try { silent = now - fs.statSync(logPath).mtimeMs > LOG_SILENCE_KILL_MS; }
catch { silent = false; } // no log file yet => fresh launch, never kill
if (!silent) continue;
try { process.kill(job.pid, 9); } catch { /* already gone */ }
await prisma.job.updateMany({
where: { id: job.id, status: 'running' },
data: { status: 'error', pid: null,
info: `[reaper] pid ${job.pid} alive but log silent >10min; killed` },
});
continue;
}
if (job.pid != null) {
await prisma.job.updateMany({
where: { id: job.id, status: 'running' },
data: { status: 'error', pid: null,
info: `[reaper] pid ${job.pid} no longer alive; job left 'running'` },
});
continue;
}
const ageMs = now - new Date(job.updated_at).getTime();
if (ageMs < NO_PID_GRACE_MS) continue;
await prisma.job.updateMany({
where: { id: job.id, status: 'running' },
data: { status: 'error',
info: '[reaper] running with no pid after grace period; launch likely failed' },
});
} catch (e) {
console.error(`[reaper] failed to inspect job ${job.id}:`, e);
}
}
}
// at the top of processQueue():
try { await reapZombieJobs(); }
catch (e) { console.error('reap sweep failed:', e); }
Safety properties we consider essential (and would keep upstream):
- Every write is
updateMany guarded by status: 'running', so the reaper can never clobber a terminal status the python side just wrote (race-safe).
- Live pids are only touched after a 10-minute log-silence threshold; missing log file = fresh launch = never kill.
pid == null rows get a 5-minute grace (pid is written a few hundred ms after spawn).
- The sweep runs before queue dispatch, so a zombie can never hold
gpu_ids busy for a round.
Verified in production on a 4×16 GB FLUX training box: stuck rows now flip to error within one sweep, the queue resumes, and no healthy job has ever been touched.
Suggested upstream fix
- Don't trust
exit code 0 as proof of a terminal DB state: after the child exits, the worker should verify the row is no longer running and, if it is, mark it from the exit code (0 → completed only if the trainer guarantees it wrote the row; otherwise error with the exit code recorded).
- Add a reaper with the semantics above (pid liveness + log-silence kill + no-pid grace), idempotent and race-safe.
- Even minimally:
processQueue should time-box the "busy" check — a running row older than N minutes with a dead pid should be treated as an error, not as busy-forever.
Environment
- ai-toolkit
main commit db8dbd6 (2026-09-15); first observed 0.13.5 (2026-09-06), re-verified on 0.13.6 and current main.
- Ubuntu 26.04, UI production mode (
npm start: worker + fileServer), Prisma DB.
- Repro: start any non-UI-managed trainer job (e.g. CLI-style
sd_trainer flow) for gpu_ids 0,1,2,3, let it exit 0 without a UI DB write → submit a normal next job → it never starts.
Summary
A single job row left at
status='running'in the DB blocks the entire queue for thatgpu_idsgroup forever:processQueuetreats anyrunningrow as "busy" andcontinues, so no further job for those GPUs is ever dispatched. This is reproducible, has happened repeatedly, and until now the only recovery was a manual DB/row cleanup.Root cause (two compounding gaps)
Non-UI trainer types never write the DB. If a job is started with a trainer type that is not the UI-managed diffusion trainer (e.g. a CLI-style trainer), the python side never writes a terminal status. The worker then trusts python to do it: in
ui/cron/actions/startJob.tsthe exit path is effectivelyso a clean exit (or a process that never ran the UI write path) leaves the row
runningforever.No fallback reaper. The worker's exit listener is lost on worker restart; a process can also hang during shutdown after training finished (log goes silent, pid still alive); or the process dies (crash/OOM/reboot) without writing its own status. None of these states is ever re-examined, so the stuck row stays.
Both scenarios hit us on a 4-GPU FLUX setup: once, after a plain exit-0 run of a non-UI trainer type; and again after a worker restart. In both cases the queue for
0,1,2,3never dispatched again until the row was marked manually.Workaround (working, what we ship in production)
A reaper sweep at the top of
processQueue(ui/cron/actions/processQueue.ts), run at most every 30 s:Safety properties we consider essential (and would keep upstream):
updateManyguarded bystatus: 'running', so the reaper can never clobber a terminal status the python side just wrote (race-safe).pid == nullrows get a 5-minute grace (pidis written a few hundred ms after spawn).gpu_idsbusy for a round.Verified in production on a 4×16 GB FLUX training box: stuck rows now flip to
errorwithin one sweep, the queue resumes, and no healthy job has ever been touched.Suggested upstream fix
exit code 0as proof of a terminal DB state: after the child exits, the worker should verify the row is no longerrunningand, if it is, mark it from the exit code (0 →completedonly if the trainer guarantees it wrote the row; otherwiseerrorwith the exit code recorded).processQueueshould time-box the "busy" check — arunningrow older than N minutes with a dead pid should be treated as an error, not as busy-forever.Environment
maincommitdb8dbd6(2026-09-15); first observed 0.13.5 (2026-09-06), re-verified on 0.13.6 and current main.npm start: worker + fileServer), Prisma DB.sd_trainerflow) forgpu_ids 0,1,2,3, let it exit 0 without a UI DB write → submit a normal next job → it never starts.