mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
109 lines
3.7 KiB
TypeScript
109 lines
3.7 KiB
TypeScript
/**
|
|||
|
|
* A terminal job row must survive a late progress frame.
|
||
|
|
*
|
||
|
|
* Tool progress is published fire and forget (`void updateSingleFileProgress`
|
||
|
|
* in the worker), so a nonterminal frame can still be in flight when the job
|
||
|
|
* reaches a terminal state. persistSingleFileProgress wrote
|
||
|
|
* `status: "processing", completedAt: null, error: null` unconditionally, so a
|
||
|
|
* frame that landed late resurrected a finished row.
|
||
|
|
*
|
||
|
|
* Observed on the release container: cancelling a stabilize-video job just
|
||
|
|
* after submit left the row permanently `status = processing`,
|
||
|
|
* `completed_at = null`, `error = null`, `duration_ms = 59`, with
|
||
|
|
* `progress = {"stage":"Analyzing","percent":5}` -- the tool's first frame,
|
||
|
|
* written after the worker's cancel row. One in three attempts reproduced it,
|
||
|
|
* and the row never recovered because nothing else revisits it.
|
||
|
|
*
|
||
|
|
* The completion path is already safe: it goes through
|
||
|
|
* updateSingleFileProgressAtomically, whose per-job queue drains earlier
|
||
|
|
* nonterminal writes first. The cancel and failure paths in the worker use a
|
||
|
|
* plain db.update outside that queue, so the guard has to live in the write
|
||
|
|
* itself.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { randomUUID } from "node:crypto";
|
||
|
|
import { eq } from "drizzle-orm";
|
||
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||
|
|
import { db, schema } from "../../../apps/api/src/db/index.js";
|
||
|
|
import { updateSingleFileProgress } from "../../../apps/api/src/routes/progress.js";
|
||
|
|
import { buildTestApp, type TestApp } from "../test-server.js";
|
||
|
|
|
||
|
|
let testApp: TestApp;
|
||
|
|
|
||
|
|
beforeAll(async () => {
|
||
|
|
testApp = await buildTestApp();
|
||
|
|
}, 30_000);
|
||
|
|
|
||
|
|
afterAll(async () => {
|
||
|
|
await testApp.cleanup();
|
||
|
|
}, 10_000);
|
||
|
|
|
||
|
|
async function seedJob(
|
||
|
|
status: "canceled" | "failed" | "completed" | "processing",
|
||
|
|
): Promise<string> {
|
||
|
|
const id = randomUUID();
|
||
|
|
await db.insert(schema.jobs).values({
|
||
|
|
id,
|
||
|
|
type: "single",
|
||
|
|
status,
|
||
|
|
inputRefs: [],
|
||
|
|
completedAt: status === "processing" ? null : new Date(),
|
||
|
|
durationMs: 59,
|
||
|
|
error: status === "failed" ? { message: "boom" } : null,
|
||
|
|
});
|
||
|
|
return id;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function readJob(id: string) {
|
||
|
|
const [row] = await db.select().from(schema.jobs).where(eq(schema.jobs.id, id));
|
||
|
|
return row;
|
||
|
|
}
|
||
|
|
|
||
|
|
describe("late progress frames against a terminal job row", () => {
|
||
|
|
for (const terminal of ["canceled", "failed", "completed"] as const) {
|
||
|
|
it(`leaves a ${terminal} row terminal`, async () => {
|
||
|
|
const id = await seedJob(terminal);
|
||
|
|
|
||
|
|
// The frame the worker had already queued before the job ended.
|
||
|
|
await updateSingleFileProgress({
|
||
|
|
jobId: id,
|
||
|
|
phase: "processing",
|
||
|
|
percent: 5,
|
||
|
|
stage: "Analyzing",
|
||
|
|
});
|
||
|
|
|
||
|
|
const row = await readJob(id);
|
||
|
|
expect(row.status, `${terminal} row was rewritten to ${row.status}`).toBe(terminal);
|
||
|
|
expect(row.completedAt, "completedAt was cleared by a late progress frame").not.toBeNull();
|
||
|
|
if (terminal === "failed") {
|
||
|
|
expect(row.error, "the failure reason was cleared by a late progress frame").not.toBeNull();
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
it("still advances a job that is genuinely still running", async () => {
|
||
|
|
const id = await seedJob("processing");
|
||
|
|
|
||
|
|
await updateSingleFileProgress({
|
||
|
|
jobId: id,
|
||
|
|
phase: "processing",
|
||
|
|
percent: 42,
|
||
|
|
stage: "Working",
|
||
|
|
});
|
||
|
|
|
||
|
|
const row = await readJob(id);
|
||
|
|
expect(row.status).toBe("processing");
|
||
|
|
expect((row.progress as { percent?: number } | null)?.percent).toBe(42);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("still records a terminal frame on a running job", async () => {
|
||
|
|
const id = await seedJob("processing");
|
||
|
|
|
||
|
|
await updateSingleFileProgress({ jobId: id, phase: "complete", percent: 100 });
|
||
|
|
|
||
|
|
const row = await readJob(id);
|
||
|
|
expect(row.status).toBe("completed");
|
||
|
|
expect(row.completedAt).not.toBeNull();
|
||
|
|
});
|
||
|
|
});
|