Files
vfxreview/lib/render-pipeline/reaper.ts
T
twotalesanimation ae58dc0366 feat(pipeline): render queue, worker service and automated preview generation
One "Queue Export" click now renders the EXR sequence, then rebuilds the shot
headlessly with the studio slate/overlay template to produce the delivery MOV
and review MP4. Implements RenderPipeline2 phases 1-2 plus the preview stage.

Server:
- New models Export, RenderJob, ExportEvent, Machine, WorkerHeartbeat, plus
  Project.deliveryConfig and per-submission slate fields (Export.vfxScope,
  Export.submissionNote, inherited from the shot's previous export).
  Both migrations are purely additive; no existing column is touched.
- lib/render-pipeline: server-enforced state machine, transactional version
  increment with supersede, atomic FOR UPDATE SKIP LOCKED claim gated by
  machine availability windows, and a lease reaper run from instrumentation.ts.
- /api/ext/* endpoints for the panel and workers; session-auth mirrors under
  /api/render and /api/machines for the web UI.
- Pipeline pages: render queue, export detail, machine monitoring, plus an
  Exports tab on shot detail.

RenderWorker (.NET 8 Windows service, new):
- Registration, heartbeat as cancel channel, claim loop, aerender runner with
  progress parsing and stall watchdog, crash recovery and disk-spooled
  reporting that survives server downtime.
- Preview stage: headless AE assembles the preview comp into a throwaway AEP
  with both output modules queued, then a single aerender pass renders them.
  Preview jobs are not claimed while an interactive AE session is open, so an
  artist's project is never taken over.

AE panel: Queue Export with live status polling, urgent flag, retry, and the
VFX Scope / Submission Note fields. Every existing panel action is unchanged.

Preview chaining ships disabled behind SystemConfig preview.enabled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 14:34:34 +02:00

95 lines
3.6 KiB
TypeScript

import { Prisma } from "@prisma/client";
import { db } from "@/lib/db";
import { applyTransition } from "./transitions";
import { getConfigNumber } from "./config";
/**
* Server-side reaper (RenderPipeline2 §7.7) — the only background job the web
* app gains. Covers worker power loss, BSOD and network partition with no
* worker cooperation:
* 1. expired leases → job EXPIRED; requeue (T5) or Export → RENDER_FAILED (T4)
* 2. silent machines → OFFLINE
* 3. prune heartbeats older than 7 days
*/
export async function reapExpiredLeases() {
const expired = await db.renderJob.findMany({
where: { status: { in: ["CLAIMED", "RUNNING"] }, leaseExpiresAt: { lt: new Date() } },
include: { export: { select: { id: true, status: true } } },
});
for (const job of expired) {
try {
await db.$transaction(async (tx) => {
// Re-check inside the transaction — the worker may have reported in between
const fresh = await tx.renderJob.findUnique({ where: { id: job.id }, select: { status: true, leaseExpiresAt: true } });
if (!fresh || !["CLAIMED", "RUNNING"].includes(fresh.status) || !fresh.leaseExpiresAt || fresh.leaseExpiresAt >= new Date()) {
return;
}
await tx.renderJob.update({
where: { id: job.id },
data: { status: "EXPIRED", finishedAt: new Date(), errorMessage: "Lease expired (worker unreachable)" },
});
if (!job.export) return;
if (job.attempt < job.maxAttempts) {
const next = await tx.renderJob.create({
data: {
type: job.type,
exportId: job.exportId,
attempt: job.attempt + 1,
maxAttempts: job.maxAttempts,
priority: job.priority,
manifest: job.manifest as Prisma.InputJsonValue,
},
});
if (job.export.status === "RENDERING") {
await applyTransition(tx, job.export, "QUEUED", {
type: "SYSTEM",
note: `Lease expired on attempt ${job.attempt} — requeued as attempt ${next.attempt}`,
});
}
} else if (job.export.status === "RENDERING") {
await applyTransition(tx, job.export, "RENDER_FAILED", {
type: "SYSTEM",
note: `Lease expired on attempt ${job.attempt}/${job.maxAttempts} — no retries remain`,
});
}
});
} catch (err) {
console.error(`[render-pipeline] reaper failed for job ${job.id}`, err);
}
}
return expired.length;
}
export async function reaperSweep() {
const heartbeatSeconds = await getConfigNumber("render.heartbeatSeconds");
await reapExpiredLeases();
await db.machine.updateMany({
where: {
status: "ONLINE",
OR: [{ lastSeenAt: null }, { lastSeenAt: { lt: new Date(Date.now() - heartbeatSeconds * 3 * 1000) } }],
},
data: { status: "OFFLINE" },
});
await db.workerHeartbeat.deleteMany({
where: { createdAt: { lt: new Date(Date.now() - 7 * 24 * 3600 * 1000) } },
});
}
const globalForReaper = globalThis as unknown as { renderPipelineReaper?: ReturnType<typeof setInterval> };
/** Started once from instrumentation.ts (Next.js server startup hook). */
export function startReaper(intervalMs = 60_000) {
if (globalForReaper.renderPipelineReaper) return;
globalForReaper.renderPipelineReaper = setInterval(() => {
reaperSweep().catch((err) => console.error("[render-pipeline] reaper sweep failed", err));
}, intervalMs);
// Don't hold the process open on shutdown
globalForReaper.renderPipelineReaper.unref?.();
console.log("[render-pipeline] reaper started (interval", intervalMs, "ms)");
}