Commit 12aff609a16a489213bbcdb15555a2d034c8982f
1 parent
a68fd8b3
Fix: make generation poll loop wall-clock instead of fixed poll count
H3's 4 GPUs run one sharded model (H3-CONSOLE.md /v1/status), not 4 independent workers, so a batch of N jobs is served ~serially. The poll loop's old fixed 200-iteration budget (~1000s at the 5s default interval) didn't account for queue depth: submitting a 9-job batch hit this live and 9/11 jobs got marked "generation timed out" locally while still sitting healthy in H3's queue (queue_position confirmed via GET /v1/videos/:id). Also treats H3's "cancelled" status as terminal, matching "error"/"failed". Recovered the wrongly-failed jobs by resetting them to "generating" in generation-jobs.json (their h3JobId was already set, so resumeDressupGenerations() picked them back up without resubmitting to H3) rather than resubmitting and burning the queue further. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011Deddn3sM3oQnmvqWSMb4y
Showing
2 changed files
with
125 additions
and
2 deletions
dressup-generate.test.js
| ... | ... | @@ -289,3 +289,116 @@ test("batch-gen: rejects missing characterId or empty garmentIds", async () => { |
| 289 | 289 | const res = await fetch(`${BASE}/api/admin/batch-gen`, authed({ method: "POST", body: JSON.stringify({ characterId: "nope", garmentIds: [] }) })); |
| 290 | 290 | assert.equal(res.status, 400); |
| 291 | 291 | }); |
| 292 | + | |
| 293 | +// Regression for a real incident (2026-09-02): a 9-job batch against H3's | |
| 294 | +// single sharded-model backend serializes almost entirely — the last job's | |
| 295 | +// queue wait alone blew past the old fixed 200-poll budget (~1000s) even | |
| 296 | +// though nothing had actually failed. generateDanceVideo's poll loop is now | |
| 297 | +// wall-clock (GENERATION_TIMEOUT_MS), independent of GENERATION_POLL_INTERVAL_MS, | |
| 298 | +// so a job stuck "queued" must be given the full configured budget before | |
| 299 | +// it's declared failed — not bailed out after a fixed number of polls. | |
| 300 | +test("generate: poll loop honors GENERATION_TIMEOUT_MS wall-clock, not a fixed poll count", async () => { | |
| 301 | + let neverDoneMockH3; | |
| 302 | + const port = await new Promise((resolve) => { | |
| 303 | + neverDoneMockH3 = http.createServer((req, res) => { | |
| 304 | + req.url = req.url.replace(/^\/+/, "/"); | |
| 305 | + const chunks = []; | |
| 306 | + req.on("data", (c) => chunks.push(c)); | |
| 307 | + req.on("end", () => { | |
| 308 | + if (req.method === "POST" && req.url === "/v1/assets") { | |
| 309 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 310 | + res.end(JSON.stringify({ id: "asset-x", url: "http://mock-h3/asset/x", kind: "image" })); | |
| 311 | + return; | |
| 312 | + } | |
| 313 | + if (req.method === "POST" && req.url === "/v1/videos") { | |
| 314 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 315 | + res.end(JSON.stringify({ id: "job-x" })); | |
| 316 | + return; | |
| 317 | + } | |
| 318 | + if (req.method === "GET" && req.url === "/v1/videos/job-x") { | |
| 319 | + // Always "queued" — simulates sitting behind a deep batch queue. | |
| 320 | + res.writeHead(200, { "Content-Type": "application/json" }); | |
| 321 | + res.end(JSON.stringify({ id: "job-x", status: "queued" })); | |
| 322 | + return; | |
| 323 | + } | |
| 324 | + res.writeHead(404, { "Content-Type": "application/json" }); | |
| 325 | + res.end(JSON.stringify({ error: "not found in mock" })); | |
| 326 | + }); | |
| 327 | + }); | |
| 328 | + neverDoneMockH3.listen(0, () => resolve(neverDoneMockH3.address().port)); | |
| 329 | + }); | |
| 330 | + | |
| 331 | + const port2 = 22934 + Math.floor(Math.random() * 1000); | |
| 332 | + const base2 = `http://localhost:${port2}`; | |
| 333 | + const cwd2 = fs.mkdtempSync(path.join(os.tmpdir(), "dressup-timeout-")); | |
| 334 | + for (const f of ["server.js", "ingest.js", "uploads.js", "store.js", "dressup-store.js", "prompt-template.js"]) { | |
| 335 | + fs.copyFileSync(path.join(__dirname, f), path.join(cwd2, f)); | |
| 336 | + } | |
| 337 | + fs.mkdirSync(path.join(cwd2, "uploads"), { recursive: true }); | |
| 338 | + fs.writeFileSync(path.join(cwd2, "uploads", "person.png"), "fake-png-bytes"); | |
| 339 | + fs.writeFileSync(path.join(cwd2, "uploads", "garment.png"), "fake-png-bytes"); | |
| 340 | + fs.writeFileSync(path.join(cwd2, "uploads", "ref.mp4"), "fake-mp4-bytes"); | |
| 341 | + | |
| 342 | + const child2 = spawn(process.execPath, ["server.js"], { | |
| 343 | + cwd: cwd2, | |
| 344 | + env: { | |
| 345 | + ...process.env, | |
| 346 | + PORT: String(port2), | |
| 347 | + H3_CONSOLE_BASE: `http://localhost:${port}`, | |
| 348 | + H3_CONSOLE_TOKEN: "mock-token", | |
| 349 | + ADMIN_PASSWORD, | |
| 350 | + GENERATION_POLL_INTERVAL_MS: "20", | |
| 351 | + GENERATION_TIMEOUT_MS: "150", | |
| 352 | + }, | |
| 353 | + stdio: "pipe", | |
| 354 | + }); | |
| 355 | + try { | |
| 356 | + await waitForServer(base2); | |
| 357 | + const loginRes = await fetch(`${base2}/api/admin/login`, { | |
| 358 | + method: "POST", | |
| 359 | + headers: { "Content-Type": "application/json" }, | |
| 360 | + body: JSON.stringify({ password: ADMIN_PASSWORD }), | |
| 361 | + }); | |
| 362 | + const cookie2 = (loginRes.headers.get("set-cookie") || "").split(";")[0]; | |
| 363 | + const authed2 = (opts = {}) => ({ ...opts, headers: { "Content-Type": "application/json", Cookie: cookie2, ...opts.headers } }); | |
| 364 | + | |
| 365 | + const character = await fetch( | |
| 366 | + `${base2}/api/admin/characters`, | |
| 367 | + authed2({ method: "POST", body: JSON.stringify({ name: "小美", imageUrl: "/uploads/person.png", appearanceDescriptor: "black hair" }) }) | |
| 368 | + ).then((r) => r.json()); | |
| 369 | + const garment = await fetch( | |
| 370 | + `${base2}/api/admin/garments`, | |
| 371 | + authed2({ method: "POST", body: JSON.stringify({ name: "红旗袍", imageUrl: "/uploads/garment.png", appearanceDescriptor: "red qipao" }) }) | |
| 372 | + ).then((r) => r.json()); | |
| 373 | + await fetch( | |
| 374 | + `${base2}/api/admin/dance-refs`, | |
| 375 | + authed2({ method: "POST", body: JSON.stringify({ name: "拉丁舞", videoUrl: "/uploads/ref.mp4", motionDescriptor: "hip sways" }) }) | |
| 376 | + ); | |
| 377 | + | |
| 378 | + const submitted = await fetch( | |
| 379 | + `${base2}/api/admin/generate`, | |
| 380 | + authed2({ method: "POST", body: JSON.stringify({ characterId: character.id, garmentId: garment.id }) }) | |
| 381 | + ).then((r) => r.json()); | |
| 382 | + | |
| 383 | + // Still "queued" well past the old 200-poll budget's equivalent | |
| 384 | + // wall-clock (200 * 20ms = 4000ms) would have been fine either way at | |
| 385 | + // this poll interval — the real proof is that it survives on "queued" | |
| 386 | + // for longer than a short timeout would allow, then correctly fails | |
| 387 | + // once GENERATION_TIMEOUT_MS (150ms) actually elapses. | |
| 388 | + const start = Date.now(); | |
| 389 | + let job; | |
| 390 | + for (let i = 0; i < 50; i++) { | |
| 391 | + job = await fetch(`${base2}/api/admin/generate/${submitted.jobId}`, authed2()).then((r) => r.json()); | |
| 392 | + if (job.status === "failed") break; | |
| 393 | + await new Promise((r) => setTimeout(r, 20)); | |
| 394 | + } | |
| 395 | + const elapsed = Date.now() - start; | |
| 396 | + assert.equal(job.status, "failed"); | |
| 397 | + assert.equal(job.error, "generation timed out"); | |
| 398 | + assert.ok(elapsed >= 150, `should not fail before the configured 150ms deadline (took ${elapsed}ms)`); | |
| 399 | + } finally { | |
| 400 | + child2.kill(); | |
| 401 | + neverDoneMockH3.close(); | |
| 402 | + fs.rmSync(cwd2, { recursive: true, force: true }); | |
| 403 | + } | |
| 404 | +}); | ... | ... |
server.js
| ... | ... | @@ -771,7 +771,15 @@ async function generateDanceVideo(job, opts = {}) { |
| 771 | 771 | job = generationJobs.update(job.id, { h3JobId: submitted.id, status: "generating" }); |
| 772 | 772 | } |
| 773 | 773 | |
| 774 | - for (let i = 0; i < 200; i++) { | |
| 774 | + // ponytail: H3's 4 GPUs run one sharded model, not 4 independent | |
| 775 | + // workers (H3-CONSOLE.md /v1/status), so a batch of N jobs is served | |
| 776 | + // ~serially — the Nth job in queue_position can sit "queued" for | |
| 777 | + // N * ~700s before it even starts. A fixed poll-count budget doesn't | |
| 778 | + // know about queue depth, so this is wall-clock and generous instead; | |
| 779 | + // upgrade path if batches grow past a couple dozen: read `eta_seconds`/ | |
| 780 | + // `queue_position` off the poll response and size the deadline to that. | |
| 781 | + const deadline = Date.now() + (opts.timeoutMs || Number(process.env.GENERATION_TIMEOUT_MS) || 2 * 60 * 60 * 1000); | |
| 782 | + while (Date.now() < deadline) { | |
| 775 | 783 | await new Promise((r) => setTimeout(r, pollIntervalMs)); |
| 776 | 784 | const polled = await h3ConsoleFetch(`/v1/videos/${job.h3JobId}`); |
| 777 | 785 | if (polled.deleted_at) throw new Error("job was deleted on the H3 console before it finished"); |
| ... | ... | @@ -808,7 +816,9 @@ async function generateDanceVideo(job, opts = {}) { |
| 808 | 816 | generationJobs.update(job.id, { status: "done", generatedVideoId: clip.id }); |
| 809 | 817 | return clip; |
| 810 | 818 | } |
| 811 | - if (polled.status === "error" || polled.status === "failed") throw new Error(polled.error || "generation failed"); | |
| 819 | + if (polled.status === "error" || polled.status === "failed" || polled.status === "cancelled") { | |
| 820 | + throw new Error(polled.error || `generation ${polled.status}`); | |
| 821 | + } | |
| 812 | 822 | } |
| 813 | 823 | throw new Error("generation timed out"); |
| 814 | 824 | } catch (err) { | ... | ... |
-
mentioned in commit 29f24a7074f777ec2e4b39ffb7a878157dc4193f