2026-07-15 03:34:24 +08:00
|
|
|
import { setTimeout as delay } from "node:timers/promises";
|
|
|
|
|
import { eq } from "drizzle-orm";
|
2026-07-27 15:37:30 +08:00
|
|
|
import type { FastifyInstance } from "fastify";
|
2026-07-27 12:16:48 +08:00
|
|
|
import { expect } from "vitest";
|
2026-07-15 03:34:24 +08:00
|
|
|
import { db, schema } from "../../apps/api/src/db/index.js";
|
|
|
|
|
import { requestCancel } from "../../apps/api/src/jobs/cancel.js";
|
|
|
|
|
import { waitForJob } from "../../apps/api/src/jobs/enqueue.js";
|
|
|
|
|
import { getQueue } from "../../apps/api/src/jobs/queues.js";
|
|
|
|
|
import type { Pool, ToolJobResult } from "../../apps/api/src/jobs/types.js";
|
2026-07-27 15:37:30 +08:00
|
|
|
import { resolveToolPool } from "../../apps/api/src/lib/pool.js";
|
2026-07-15 03:34:24 +08:00
|
|
|
|
|
|
|
|
const LIVE_DATABASE_STATUSES = new Set(["queued", "processing"]);
|
|
|
|
|
const TERMINAL_QUEUE_STATES = new Set(["completed", "failed"]);
|
|
|
|
|
|
2026-07-27 15:37:30 +08:00
|
|
|
export interface DownloadedJobArtifact {
|
|
|
|
|
buffer: Buffer;
|
|
|
|
|
contentType: string;
|
|
|
|
|
filename: string;
|
|
|
|
|
result: ToolJobResult;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 03:34:24 +08:00
|
|
|
export class AcceptedJobTimeoutError extends Error {
|
|
|
|
|
constructor(jobId: string, timeoutMs: number) {
|
|
|
|
|
super(`Job ${jobId} did not finish within ${timeoutMs}ms and was canceled`);
|
|
|
|
|
this.name = "AcceptedJobTimeoutError";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Cancel an accepted integration-test job and wait until both the durable row
|
|
|
|
|
* and BullMQ agree that its worker has stopped. A 200 cancel response only
|
|
|
|
|
* means the signal was sent; returning earlier can leak CPU-heavy work into
|
|
|
|
|
* another test or make worker teardown time out.
|
|
|
|
|
*/
|
|
|
|
|
export async function cancelAcceptedJobAndWait(
|
|
|
|
|
jobId: string,
|
|
|
|
|
pool: Pool,
|
|
|
|
|
timeoutMs = 30_000,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
let lastDatabaseStatus = "missing";
|
|
|
|
|
let lastQueueState = "missing";
|
|
|
|
|
let lastCancelError: unknown;
|
|
|
|
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
try {
|
|
|
|
|
await requestCancel(jobId);
|
|
|
|
|
lastCancelError = undefined;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
// BullMQ can move a job from waiting to active between inspection and
|
|
|
|
|
// removal. Retry until either cancellation lands or the job terminates.
|
|
|
|
|
lastCancelError = error;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [row] = await db
|
|
|
|
|
.select({ status: schema.jobs.status })
|
|
|
|
|
.from(schema.jobs)
|
|
|
|
|
.where(eq(schema.jobs.id, jobId));
|
|
|
|
|
lastDatabaseStatus = row?.status ?? "missing";
|
|
|
|
|
|
|
|
|
|
const job = await getQueue(pool).getJob(jobId);
|
|
|
|
|
lastQueueState = job ? await job.getState() : "missing";
|
|
|
|
|
|
|
|
|
|
const databaseSettled = Boolean(row && !LIVE_DATABASE_STATUSES.has(row.status));
|
|
|
|
|
const queueSettled = lastQueueState === "missing" || TERMINAL_QUEUE_STATES.has(lastQueueState);
|
|
|
|
|
if (databaseSettled && queueSettled) return row.status;
|
|
|
|
|
|
|
|
|
|
await delay(100);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cancelDetails =
|
|
|
|
|
lastCancelError instanceof Error ? `; last cancel error: ${lastCancelError.message}` : "";
|
|
|
|
|
throw new Error(
|
|
|
|
|
`Job ${jobId} did not settle after cancellation ` +
|
|
|
|
|
`(database=${lastDatabaseStatus}, queue=${lastQueueState})${cancelDetails}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 12:16:48 +08:00
|
|
|
const TERMINAL_DATABASE_STATUSES = new Set(["completed", "failed", "canceled"]);
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Settle a 202 async-fallback response, and assert the job actually finished.
|
|
|
|
|
*
|
|
|
|
|
* A 202 means the sync window expired while the job was still running. Tests
|
|
|
|
|
* used to `return` here, which asserted nothing beyond the envelope and left
|
|
|
|
|
* the job running into whatever test came next (the leak
|
|
|
|
|
* `cancelAcceptedJobAndWait` exists to prevent). Because the window only
|
|
|
|
|
* expires under load, that made coverage depend on how busy the runner was: on
|
|
|
|
|
* CI 44 tests took this path and checked nothing, while the same tests on a dev
|
|
|
|
|
* machine finished inside the window and asserted in full.
|
|
|
|
|
*
|
|
|
|
|
* Waits for a terminal state instead and asserts what every caller actually
|
|
|
|
|
* cares about: the job finished, and if it failed it failed cleanly with a
|
|
|
|
|
* message rather than crashing. A clean failure is a legitimate outcome here,
|
|
|
|
|
* since the exotic-format fixtures are expected to be rejected.
|
|
|
|
|
*
|
|
|
|
|
* Returns true when the response was a 202 so callers keep their existing
|
|
|
|
|
* early-return shape; a 202 body carries no result to assert against.
|
|
|
|
|
*/
|
|
|
|
|
export async function settleAsyncFallback(
|
|
|
|
|
res: { statusCode: number; body: string },
|
|
|
|
|
timeoutMs = 120_000,
|
|
|
|
|
): Promise<boolean> {
|
|
|
|
|
if (res.statusCode !== 202) return false;
|
|
|
|
|
|
|
|
|
|
const body = JSON.parse(res.body) as { async?: boolean; jobId?: string };
|
|
|
|
|
expect(body.async).toBe(true);
|
|
|
|
|
expect(body.jobId).toBeDefined();
|
|
|
|
|
const jobId = body.jobId as string;
|
|
|
|
|
|
|
|
|
|
const deadline = Date.now() + timeoutMs;
|
|
|
|
|
let row: { status: string; pool: string | null; error: { message: string } | null } | undefined;
|
|
|
|
|
|
|
|
|
|
while (Date.now() < deadline) {
|
|
|
|
|
[row] = await db
|
|
|
|
|
.select({ status: schema.jobs.status, pool: schema.jobs.pool, error: schema.jobs.error })
|
|
|
|
|
.from(schema.jobs)
|
|
|
|
|
.where(eq(schema.jobs.id, jobId));
|
|
|
|
|
if (row && TERMINAL_DATABASE_STATUSES.has(row.status)) break;
|
|
|
|
|
await delay(100);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!row || !TERMINAL_DATABASE_STATUSES.has(row.status)) {
|
|
|
|
|
// Never leave it running: a stuck job starves every later test in the fork.
|
|
|
|
|
await cancelAcceptedJobAndWait(jobId, (row?.pool as Pool | undefined) ?? "image");
|
|
|
|
|
throw new AcceptedJobTimeoutError(jobId, timeoutMs);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (row.status === "failed") {
|
|
|
|
|
expect(typeof row.error?.message, `job ${jobId} failed without a message`).toBe("string");
|
|
|
|
|
expect(row.error?.message.length ?? 0).toBeGreaterThan(0);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 03:34:24 +08:00
|
|
|
/**
|
|
|
|
|
* Wait for an accepted job to succeed. If the observation window expires,
|
|
|
|
|
* cancel and fully drain the job before failing the test so timed-out work can
|
|
|
|
|
* never starve another integration fork.
|
|
|
|
|
*/
|
|
|
|
|
export async function waitForAcceptedJobOrCancel(
|
|
|
|
|
jobId: string,
|
|
|
|
|
pool: Pool,
|
|
|
|
|
timeoutMs: number,
|
|
|
|
|
): Promise<ToolJobResult> {
|
|
|
|
|
const result = await waitForJob(pool, jobId, timeoutMs);
|
|
|
|
|
if (result) return result;
|
|
|
|
|
|
|
|
|
|
await cancelAcceptedJobAndWait(jobId, pool);
|
|
|
|
|
throw new AcceptedJobTimeoutError(jobId, timeoutMs);
|
|
|
|
|
}
|
2026-07-27 15:37:30 +08:00
|
|
|
|
|
|
|
|
async function downloadCompletedJobArtifact(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
token: string,
|
|
|
|
|
toolId: string,
|
|
|
|
|
jobId: string,
|
|
|
|
|
result: ToolJobResult,
|
|
|
|
|
): Promise<DownloadedJobArtifact> {
|
|
|
|
|
if (result.outputRefs.length === 0) {
|
|
|
|
|
throw new Error(`${toolId}: completed job ${jobId} produced no downloadable artifact`);
|
|
|
|
|
}
|
|
|
|
|
if (!result.filename) throw new Error(`${toolId}: completed job ${jobId} has no filename`);
|
|
|
|
|
|
|
|
|
|
const download = await app.inject({
|
|
|
|
|
method: "GET",
|
|
|
|
|
url: `/api/v1/download/${encodeURIComponent(jobId)}/${encodeURIComponent(result.filename)}`,
|
|
|
|
|
headers: { authorization: `Bearer ${token}` },
|
|
|
|
|
});
|
|
|
|
|
if (download.statusCode !== 200) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${toolId}: completed job ${jobId} artifact download returned ${download.statusCode}`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (download.rawPayload.length === 0) {
|
|
|
|
|
throw new Error(`${toolId}: completed job ${jobId} produced an empty artifact`);
|
|
|
|
|
}
|
|
|
|
|
if (result.processedSize <= 0 || download.rawPayload.length !== result.processedSize) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${toolId}: completed job ${jobId} artifact size mismatch ` +
|
|
|
|
|
`(worker=${result.processedSize}, downloaded=${download.rawPayload.length})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const downloadedType = String(download.headers["content-type"] ?? "")
|
|
|
|
|
.split(";", 1)[0]
|
|
|
|
|
.toLowerCase();
|
|
|
|
|
const workerType = result.contentType.split(";", 1)[0].toLowerCase();
|
|
|
|
|
if (!downloadedType || downloadedType !== workerType) {
|
|
|
|
|
throw new Error(
|
|
|
|
|
`${toolId}: completed job ${jobId} artifact MIME mismatch ` +
|
|
|
|
|
`(worker=${workerType}, downloaded=${downloadedType || "missing"})`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
buffer: Buffer.from(download.rawPayload),
|
|
|
|
|
contentType: downloadedType,
|
|
|
|
|
filename: result.filename,
|
|
|
|
|
result,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Wait for terminal success and return verified downloadable bytes. Installed
|
|
|
|
|
* capability tests use this when output semantics, not queue admission, are the
|
|
|
|
|
* release contract.
|
|
|
|
|
*/
|
|
|
|
|
export async function waitForDownloadedJobArtifact(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
token: string,
|
|
|
|
|
toolId: string,
|
|
|
|
|
jobId: string,
|
|
|
|
|
timeoutMs = 120_000,
|
|
|
|
|
): Promise<DownloadedJobArtifact> {
|
|
|
|
|
const result = await waitForAcceptedJobOrCancel(jobId, resolveToolPool(toolId), timeoutMs);
|
|
|
|
|
return downloadCompletedJobArtifact(app, token, toolId, jobId, result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Resolve a generated-matrix 202 through terminal success and prove that its
|
|
|
|
|
* worker result is observable as either a downloadable artifact or a
|
|
|
|
|
* non-empty structured payload. Admission alone is never coverage.
|
|
|
|
|
*/
|
|
|
|
|
export async function waitForGeneratedJobArtifact(
|
|
|
|
|
app: FastifyInstance,
|
|
|
|
|
token: string,
|
|
|
|
|
toolId: string,
|
|
|
|
|
jobId: string,
|
|
|
|
|
timeoutMs = 120_000,
|
|
|
|
|
): Promise<ToolJobResult> {
|
|
|
|
|
const result = await waitForAcceptedJobOrCancel(jobId, resolveToolPool(toolId), timeoutMs);
|
|
|
|
|
if (result.outputRefs.length > 0) {
|
|
|
|
|
await downloadCompletedJobArtifact(app, token, toolId, jobId, result);
|
|
|
|
|
return result;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = result.resultPayload;
|
|
|
|
|
if (!payload || Object.keys(payload).length === 0) {
|
|
|
|
|
throw new Error(`${toolId}: completed job ${jobId} produced no artifact or result payload`);
|
|
|
|
|
}
|
|
|
|
|
if (payload.success === false || payload.error !== undefined) {
|
|
|
|
|
// Carry the worker's own message. Without it every failure reads alike, and
|
|
|
|
|
// a caller cannot tell a product defect from a host with no ffmpeg.
|
|
|
|
|
const detail =
|
|
|
|
|
typeof payload.error === "string"
|
|
|
|
|
? payload.error
|
|
|
|
|
: ((payload.error as { message?: unknown } | undefined)?.message ?? "");
|
|
|
|
|
const suffix = detail ? `: ${String(detail)}` : "";
|
|
|
|
|
throw new Error(`${toolId}: completed job ${jobId} returned a failure payload${suffix}`);
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}
|