mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 14:50:02 +00:00
fix(render): publish artifacts atomically (#2154)
This commit is contained in:
@@ -0,0 +1,202 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
existsSync,
|
||||||
|
lstatSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync as renamePathSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { dirname, join } from "node:path";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { ArtifactTransaction } from "./artifactTransaction.js";
|
||||||
|
|
||||||
|
function tempDir(): string {
|
||||||
|
return mkdtempSync(join(tmpdir(), "hf-artifact-transaction-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function transactionDirectories(dir: string): string[] {
|
||||||
|
return readdirSync(dir).filter((name) => name.includes(".hf-transaction-"));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ArtifactTransaction", () => {
|
||||||
|
it("uses a private, atomically reserved sibling directory", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "render.mp4");
|
||||||
|
const transaction = new ArtifactTransaction(destination, "file");
|
||||||
|
const transactionDir = dirname(transaction.stagingPath);
|
||||||
|
|
||||||
|
expect(dirname(transactionDir)).toBe(dir);
|
||||||
|
expect(transaction.stagingPath).toBe(join(transactionDir, "render.mp4"));
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
expect(lstatSync(transactionDir).mode & 0o777).toBe(0o700);
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.rollback();
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("atomically replaces a file only after validation", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "render.mp4");
|
||||||
|
writeFileSync(destination, "existing");
|
||||||
|
const transaction = new ArtifactTransaction(destination, "file");
|
||||||
|
writeFileSync(transaction.stagingPath, "new-render");
|
||||||
|
|
||||||
|
transaction.commit();
|
||||||
|
|
||||||
|
expect(readFileSync(destination, "utf8")).toBe("new-render");
|
||||||
|
expect(existsSync(transaction.stagingPath)).toBe(false);
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves an existing file byte-identical when validation fails", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "render.gif");
|
||||||
|
const existing = Buffer.from([0, 1, 2, 3, 255]);
|
||||||
|
writeFileSync(destination, existing);
|
||||||
|
const transaction = new ArtifactTransaction(destination, "file");
|
||||||
|
writeFileSync(transaction.stagingPath, "");
|
||||||
|
|
||||||
|
expect(() => transaction.commit()).toThrow("not a non-empty file");
|
||||||
|
transaction.rollback();
|
||||||
|
|
||||||
|
expect(readFileSync(destination)).toEqual(existing);
|
||||||
|
expect(existsSync(transaction.stagingPath)).toBe(false);
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the existing file addressable when atomic promotion fails", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "render.mp4");
|
||||||
|
writeFileSync(destination, "existing");
|
||||||
|
let replacementCalls = 0;
|
||||||
|
const transaction = new ArtifactTransaction(destination, "file", {
|
||||||
|
existsSync,
|
||||||
|
renameSync(source, target) {
|
||||||
|
replacementCalls += 1;
|
||||||
|
expect(source).toBe(transaction.stagingPath);
|
||||||
|
expect(target).toBe(destination);
|
||||||
|
expect(readFileSync(destination, "utf8")).toBe("existing");
|
||||||
|
throw new Error("injected replacement failure");
|
||||||
|
},
|
||||||
|
rmSync,
|
||||||
|
});
|
||||||
|
writeFileSync(transaction.stagingPath, "new-render");
|
||||||
|
|
||||||
|
expect(() => transaction.commit()).toThrow("injected replacement failure");
|
||||||
|
expect(replacementCalls).toBe(1);
|
||||||
|
expect(readFileSync(destination, "utf8")).toBe("existing");
|
||||||
|
|
||||||
|
transaction.rollback();
|
||||||
|
expect(existsSync(transaction.stagingPath)).toBe(false);
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes cancelled staging output without touching the destination", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "render.mp4");
|
||||||
|
writeFileSync(destination, "keep-me");
|
||||||
|
const transaction = new ArtifactTransaction(destination, "file");
|
||||||
|
writeFileSync(transaction.stagingPath, "partial-render");
|
||||||
|
|
||||||
|
transaction.rollback();
|
||||||
|
|
||||||
|
expect(readFileSync(destination, "utf8")).toBe("keep-me");
|
||||||
|
expect(existsSync(transaction.stagingPath)).toBe(false);
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("promotes a validated PNG sequence as one directory artifact", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "frames");
|
||||||
|
mkdirSync(destination);
|
||||||
|
writeFileSync(join(destination, "frame_000001.png"), "old");
|
||||||
|
const transaction = new ArtifactTransaction(destination, "directory");
|
||||||
|
mkdirSync(transaction.stagingPath);
|
||||||
|
writeFileSync(join(transaction.stagingPath, "frame_000001.png"), "png-1");
|
||||||
|
writeFileSync(join(transaction.stagingPath, "frame_000002.png"), "png-2");
|
||||||
|
|
||||||
|
transaction.commit();
|
||||||
|
|
||||||
|
expect(readdirSync(destination).sort()).toEqual(["frame_000001.png", "frame_000002.png"]);
|
||||||
|
expect(readFileSync(join(destination, "frame_000001.png"), "utf8")).toBe("png-1");
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores the previous PNG sequence when directory promotion fails", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "frames");
|
||||||
|
mkdirSync(destination);
|
||||||
|
writeFileSync(join(destination, "frame_000001.png"), "existing-frame");
|
||||||
|
let transaction: ArtifactTransaction;
|
||||||
|
transaction = new ArtifactTransaction(destination, "directory", {
|
||||||
|
existsSync,
|
||||||
|
renameSync(source, target) {
|
||||||
|
if (source === transaction.stagingPath && target === destination) {
|
||||||
|
throw new Error("injected directory promotion failure");
|
||||||
|
}
|
||||||
|
renamePathSync(source, target);
|
||||||
|
},
|
||||||
|
rmSync,
|
||||||
|
});
|
||||||
|
mkdirSync(transaction.stagingPath);
|
||||||
|
writeFileSync(join(transaction.stagingPath, "frame_000001.png"), "new-frame");
|
||||||
|
|
||||||
|
expect(() => transaction.commit()).toThrow("injected directory promotion failure");
|
||||||
|
|
||||||
|
expect(readFileSync(join(destination, "frame_000001.png"), "utf8")).toBe("existing-frame");
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not delete a concurrent PNG sequence published during recovery", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "frames");
|
||||||
|
mkdirSync(destination);
|
||||||
|
writeFileSync(join(destination, "frame_000001.png"), "existing-frame");
|
||||||
|
const concurrentTransaction = new ArtifactTransaction(destination, "directory");
|
||||||
|
mkdirSync(concurrentTransaction.stagingPath);
|
||||||
|
writeFileSync(join(concurrentTransaction.stagingPath, "frame_000001.png"), "concurrent-frame");
|
||||||
|
|
||||||
|
let firstTransaction: ArtifactTransaction;
|
||||||
|
firstTransaction = new ArtifactTransaction(destination, "directory", {
|
||||||
|
existsSync,
|
||||||
|
renameSync(source, target) {
|
||||||
|
if (source === firstTransaction.stagingPath && target === destination) {
|
||||||
|
concurrentTransaction.commit();
|
||||||
|
expect(readFileSync(join(destination, "frame_000001.png"), "utf8")).toBe(
|
||||||
|
"concurrent-frame",
|
||||||
|
);
|
||||||
|
throw new Error("injected first promotion failure");
|
||||||
|
}
|
||||||
|
renamePathSync(source, target);
|
||||||
|
},
|
||||||
|
rmSync,
|
||||||
|
});
|
||||||
|
mkdirSync(firstTransaction.stagingPath);
|
||||||
|
writeFileSync(join(firstTransaction.stagingPath, "frame_000001.png"), "first-frame");
|
||||||
|
|
||||||
|
expect(() => firstTransaction.commit()).toThrow("injected first promotion failure");
|
||||||
|
|
||||||
|
expect(readFileSync(join(destination, "frame_000001.png"), "utf8")).toBe("concurrent-frame");
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an empty PNG sequence and preserves the existing directory", () => {
|
||||||
|
const dir = tempDir();
|
||||||
|
const destination = join(dir, "frames");
|
||||||
|
mkdirSync(destination);
|
||||||
|
writeFileSync(join(destination, "frame_000001.png"), "existing-frame");
|
||||||
|
const transaction = new ArtifactTransaction(destination, "directory");
|
||||||
|
mkdirSync(transaction.stagingPath);
|
||||||
|
|
||||||
|
expect(() => transaction.commit()).toThrow("directory is empty");
|
||||||
|
transaction.rollback();
|
||||||
|
|
||||||
|
expect(readFileSync(join(destination, "frame_000001.png"), "utf8")).toBe("existing-frame");
|
||||||
|
expect(transactionDirectories(dir)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import {
|
||||||
|
closeSync,
|
||||||
|
existsSync,
|
||||||
|
fstatSync,
|
||||||
|
mkdtempSync,
|
||||||
|
openSync,
|
||||||
|
readSync,
|
||||||
|
readdirSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||||
|
|
||||||
|
export type ArtifactKind = "file" | "directory";
|
||||||
|
|
||||||
|
export interface ArtifactTransactionFileSystem {
|
||||||
|
existsSync(path: string): boolean;
|
||||||
|
renameSync(source: string, destination: string): void;
|
||||||
|
rmSync(path: string, options: { recursive: true; force: true }): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultFileSystem: ArtifactTransactionFileSystem = {
|
||||||
|
existsSync,
|
||||||
|
renameSync,
|
||||||
|
rmSync,
|
||||||
|
};
|
||||||
|
|
||||||
|
function createSiblingTransactionDirectory(destination: string): string {
|
||||||
|
const parent = dirname(destination);
|
||||||
|
const extension = extname(destination);
|
||||||
|
const stem = extension ? basename(destination, extension) : basename(destination);
|
||||||
|
// mkdtemp reserves the directory atomically and creates it with private
|
||||||
|
// permissions. Keeping it beside the destination preserves same-filesystem
|
||||||
|
// rename semantics without exposing predictable files in a shared temp dir.
|
||||||
|
return mkdtempSync(join(parent, `.${stem}.hf-transaction-`));
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertReadableNonEmptyFile(path: string): void {
|
||||||
|
// Validate the opened object, not a pathname checked before opening it.
|
||||||
|
// The descriptor pins the file across validation and closes the TOCTOU gap
|
||||||
|
// between lstat(path) and open(path).
|
||||||
|
const fd = openSync(path, "r");
|
||||||
|
try {
|
||||||
|
const stat = fstatSync(fd);
|
||||||
|
if (!stat.isFile() || stat.size <= 0) {
|
||||||
|
throw new Error(`Render artifact is not a non-empty file: ${path}`);
|
||||||
|
}
|
||||||
|
readSync(fd, Buffer.allocUnsafe(1), 0, 1, 0);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDirectoryFiles(root: string): string[] {
|
||||||
|
const files: string[] = [];
|
||||||
|
const visit = (directory: string): void => {
|
||||||
|
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||||
|
const path = join(directory, entry.name);
|
||||||
|
if (entry.isDirectory()) visit(path);
|
||||||
|
else if (entry.isFile()) files.push(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages a render beside its final destination and promotes only a validated
|
||||||
|
* artifact. File promotion uses one atomic replacement rename, so an existing
|
||||||
|
* file remains addressable until the new file replaces it. Replacing a
|
||||||
|
* non-empty directory cannot be expressed as one portable rename; that case
|
||||||
|
* uses a recoverable backup handoff while preserving the previous contents on
|
||||||
|
* ordinary failures.
|
||||||
|
*/
|
||||||
|
export class ArtifactTransaction {
|
||||||
|
readonly destinationPath: string;
|
||||||
|
readonly stagingPath: string;
|
||||||
|
private readonly transactionDirectory: string;
|
||||||
|
private readonly backupPath: string;
|
||||||
|
private state: "active" | "committed" | "rolled-back" = "active";
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
destinationPath: string,
|
||||||
|
private readonly kind: ArtifactKind,
|
||||||
|
private readonly fileSystem: ArtifactTransactionFileSystem = defaultFileSystem,
|
||||||
|
) {
|
||||||
|
this.destinationPath = resolve(destinationPath);
|
||||||
|
this.transactionDirectory = createSiblingTransactionDirectory(this.destinationPath);
|
||||||
|
this.stagingPath = join(this.transactionDirectory, basename(this.destinationPath));
|
||||||
|
this.backupPath = join(this.transactionDirectory, "backup");
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(): void {
|
||||||
|
if (this.kind === "file") {
|
||||||
|
assertReadableNonEmptyFile(this.stagingPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = collectDirectoryFiles(this.stagingPath);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`Render artifact is not a readable directory: ${this.stagingPath}`, {
|
||||||
|
cause: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (files.length === 0) {
|
||||||
|
throw new Error(`Render artifact directory is empty: ${this.stagingPath}`);
|
||||||
|
}
|
||||||
|
for (const file of files) assertReadableNonEmptyFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
commit(): void {
|
||||||
|
if (this.state !== "active") {
|
||||||
|
throw new Error(`Cannot commit an artifact transaction in state ${this.state}`);
|
||||||
|
}
|
||||||
|
this.validate();
|
||||||
|
const hadDestination = this.fileSystem.existsSync(this.destinationPath);
|
||||||
|
|
||||||
|
// Both files and new directories publish with one rename. In particular,
|
||||||
|
// do not move an existing file out of the way first: rename replaces it
|
||||||
|
// atomically, so readers see either the complete old file or the complete
|
||||||
|
// new file and a failed rename leaves the old file untouched.
|
||||||
|
if (this.kind === "file" || !hadDestination) {
|
||||||
|
this.fileSystem.renameSync(this.stagingPath, this.destinationPath);
|
||||||
|
this.state = "committed";
|
||||||
|
this.cleanupTransactionDirectory();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Portable Node filesystem APIs cannot atomically replace a non-empty
|
||||||
|
// directory. Keep the backup handoff for an existing PNG-sequence output;
|
||||||
|
// it provides recovery on ordinary errors, but not atomic visibility or
|
||||||
|
// crash recovery. Callers should publish directory outputs to a fresh path
|
||||||
|
// when they require the same visibility guarantee as file artifacts.
|
||||||
|
this.fileSystem.renameSync(this.destinationPath, this.backupPath);
|
||||||
|
try {
|
||||||
|
this.fileSystem.renameSync(this.stagingPath, this.destinationPath);
|
||||||
|
this.state = "committed";
|
||||||
|
} catch (error) {
|
||||||
|
// A competing transaction may have published while this destination was
|
||||||
|
// temporarily vacant. Never remove that caller-visible directory: only
|
||||||
|
// restore our backup while the destination remains unclaimed.
|
||||||
|
this.restoreBackupIfDestinationUnclaimed();
|
||||||
|
this.state = "rolled-back";
|
||||||
|
this.cleanupTransactionDirectory();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Promotion succeeded. Cleanup is best-effort: a stale private transaction
|
||||||
|
// directory is safer than reporting failure after publishing the artifact.
|
||||||
|
this.cleanupTransactionDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
rollback(): void {
|
||||||
|
if (this.state !== "active") return;
|
||||||
|
this.fileSystem.rmSync(this.stagingPath, { recursive: true, force: true });
|
||||||
|
if (
|
||||||
|
this.fileSystem.existsSync(this.backupPath) &&
|
||||||
|
!this.fileSystem.existsSync(this.destinationPath)
|
||||||
|
) {
|
||||||
|
this.fileSystem.renameSync(this.backupPath, this.destinationPath);
|
||||||
|
}
|
||||||
|
this.cleanupTransactionDirectory();
|
||||||
|
this.state = "rolled-back";
|
||||||
|
}
|
||||||
|
|
||||||
|
private restoreBackupIfDestinationUnclaimed(): void {
|
||||||
|
if (
|
||||||
|
!this.fileSystem.existsSync(this.backupPath) ||
|
||||||
|
this.fileSystem.existsSync(this.destinationPath)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.fileSystem.renameSync(this.backupPath, this.destinationPath);
|
||||||
|
} catch (error) {
|
||||||
|
// Losing the rename race to a concurrent publisher is successful
|
||||||
|
// recovery: its complete artifact owns the destination. Propagate other
|
||||||
|
// restore failures so the caller can retry rollback with the backup kept.
|
||||||
|
if (!this.fileSystem.existsSync(this.destinationPath)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private cleanupTransactionDirectory(): void {
|
||||||
|
try {
|
||||||
|
this.fileSystem.rmSync(this.transactionDirectory, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Never turn a successfully published artifact into a failed render just
|
||||||
|
// because best-effort cleanup of its private transaction directory failed.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -95,6 +95,7 @@ import {
|
|||||||
OrderedRenderEventPublisher,
|
OrderedRenderEventPublisher,
|
||||||
publishRenderFailure,
|
publishRenderFailure,
|
||||||
} from "./render/renderEventPublisher.js";
|
} from "./render/renderEventPublisher.js";
|
||||||
|
import { ArtifactTransaction } from "./render/artifactTransaction.js";
|
||||||
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
import { normalizeErrorMessage } from "../utils/errorMessage.js";
|
||||||
import { formatCaptureFrameName } from "../utils/paths.js";
|
import { formatCaptureFrameName } from "../utils/paths.js";
|
||||||
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
|
import { resolveEffectiveHdrMode } from "./render/hdrMode.js";
|
||||||
@@ -1537,6 +1538,11 @@ export async function executeRenderJob(
|
|||||||
const isMov = outputFormat === "mov";
|
const isMov = outputFormat === "mov";
|
||||||
const isPngSequence = outputFormat === "png-sequence";
|
const isPngSequence = outputFormat === "png-sequence";
|
||||||
const isGif = outputFormat === "gif";
|
const isGif = outputFormat === "gif";
|
||||||
|
const artifactTransaction = new ArtifactTransaction(
|
||||||
|
outputPath,
|
||||||
|
isPngSequence ? "directory" : "file",
|
||||||
|
);
|
||||||
|
const stagedOutputPath = artifactTransaction.stagingPath;
|
||||||
const needsAlpha = isWebm || isMov || isPngSequence;
|
const needsAlpha = isWebm || isMov || isPngSequence;
|
||||||
// `forceScreenshot` is resolved exactly once inside `compileStage` (alpha
|
// `forceScreenshot` is resolved exactly once inside `compileStage` (alpha
|
||||||
// output + composition `renderModeHints` are folded together there) and
|
// output + composition `renderModeHints` are folded together there) and
|
||||||
@@ -2901,7 +2907,7 @@ export async function executeRenderJob(
|
|||||||
runEncodeStage({
|
runEncodeStage({
|
||||||
job,
|
job,
|
||||||
log,
|
log,
|
||||||
outputPath,
|
outputPath: stagedOutputPath,
|
||||||
framesDir,
|
framesDir,
|
||||||
videoOnlyPath,
|
videoOnlyPath,
|
||||||
width,
|
width,
|
||||||
@@ -2959,7 +2965,7 @@ export async function executeRenderJob(
|
|||||||
job,
|
job,
|
||||||
videoOnlyPath,
|
videoOnlyPath,
|
||||||
audioOutputPath,
|
audioOutputPath,
|
||||||
outputPath,
|
outputPath: stagedOutputPath,
|
||||||
hasAudio,
|
hasAudio,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
assertNotAborted,
|
assertNotAborted,
|
||||||
@@ -2971,10 +2977,7 @@ export async function executeRenderJob(
|
|||||||
observability.checkpoint("assemble", `skipped for ${outputFormat}`);
|
observability.checkpoint("assemble", `skipped for ${outputFormat}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Complete ─────────────────────────────────────────────────────────
|
artifactTransaction.validate();
|
||||||
job.outputPath = outputPath;
|
|
||||||
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
|
|
||||||
await eventPublisher.flush();
|
|
||||||
|
|
||||||
const totalElapsed = Date.now() - pipelineStart;
|
const totalElapsed = Date.now() - pipelineStart;
|
||||||
|
|
||||||
@@ -2982,7 +2985,7 @@ export async function executeRenderJob(
|
|||||||
// Record transient-tab-death retry burn (recovered case) so it's visible on
|
// Record transient-tab-death retry burn (recovered case) so it's visible on
|
||||||
// dashboard 1783183, not just logs. The catch mirrors this for the failed case.
|
// dashboard 1783183, not just logs. The catch mirrors this for the failed case.
|
||||||
recordTransientRetryObservability();
|
recordTransientRetryObservability();
|
||||||
observability.checkpoint("pipeline", "completed", { totalElapsedMs: totalElapsed });
|
observability.checkpoint("pipeline", "artifact validated", { totalElapsedMs: totalElapsed });
|
||||||
const observabilitySummary = observability.summary({
|
const observabilitySummary = observability.summary({
|
||||||
lastBrowserConsole,
|
lastBrowserConsole,
|
||||||
capture: captureObservability,
|
capture: captureObservability,
|
||||||
@@ -3043,9 +3046,9 @@ export async function executeRenderJob(
|
|||||||
// easy access. Skipped for png-sequence: outputPath is a directory, not
|
// easy access. Skipped for png-sequence: outputPath is a directory, not
|
||||||
// a single file — the captured frames already live in `framesDir` under
|
// a single file — the captured frames already live in `framesDir` under
|
||||||
// workDir during a debug run anyway.
|
// workDir during a debug run anyway.
|
||||||
if (!isPngSequence && existsSync(outputPath)) {
|
if (!isPngSequence && existsSync(stagedOutputPath)) {
|
||||||
const debugOutput = join(workDir, `output${videoExt}`);
|
const debugOutput = join(workDir, `output${videoExt}`);
|
||||||
copyFileSync(outputPath, debugOutput);
|
copyFileSync(stagedOutputPath, debugOutput);
|
||||||
}
|
}
|
||||||
} else if (process.env.KEEP_TEMP === "1") {
|
} else if (process.env.KEEP_TEMP === "1") {
|
||||||
log.info("KEEP_TEMP=1 — leaving workDir on disk for inspection", { workDir });
|
log.info("KEEP_TEMP=1 — leaving workDir on disk for inspection", { workDir });
|
||||||
@@ -3058,7 +3061,13 @@ export async function executeRenderJob(
|
|||||||
log,
|
log,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
artifactTransaction.commit();
|
||||||
|
job.outputPath = outputPath;
|
||||||
|
updateJobStatus(job, "complete", "Render complete", 100, onProgress);
|
||||||
|
await eventPublisher.flush();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await safeCleanup("rollback staged artifact", () => artifactTransaction.rollback(), log);
|
||||||
if (error instanceof RenderCancelledError || abortSignal?.aborted) {
|
if (error instanceof RenderCancelledError || abortSignal?.aborted) {
|
||||||
job.error = error instanceof Error ? error.message : "render_cancelled";
|
job.error = error instanceof Error ? error.message : "render_cancelled";
|
||||||
updateJobStatus(job, "cancelled", "Render cancelled", job.progress, onProgress);
|
updateJobStatus(job, "cancelled", "Render cancelled", job.progress, onProgress);
|
||||||
|
|||||||
Reference in New Issue
Block a user