163 lines
7.1 KiB
TypeScript
163 lines
7.1 KiB
TypeScript
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
|
import { json } from "@remix-run/server-runtime";
|
|
import { RescheduleRunRequestBody } from "@trigger.dev/core/v3/schemas";
|
|
import { z } from "zod";
|
|
import { getApiVersion } from "~/api/versions";
|
|
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
|
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
|
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
|
import { RescheduleTaskRunService } from "~/v3/services/rescheduleTaskRun.server";
|
|
import { mutateWithFallback } from "~/v3/mollifier/mutateWithFallback.server";
|
|
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
|
|
import { parseDelay } from "~/utils/delays";
|
|
|
|
const ParamsSchema = z.object({
|
|
runParam: z.string(),
|
|
});
|
|
|
|
export async function action({ request, params }: ActionFunctionArgs) {
|
|
if (request.method.toUpperCase() !== "POST") {
|
|
return { status: 405, body: "Method Not Allowed" };
|
|
}
|
|
|
|
const authenticationResult = await authenticateApiRequest(request);
|
|
if (!authenticationResult) {
|
|
return json({ error: "Invalid or missing API Key" }, { status: 401 });
|
|
}
|
|
|
|
const parsed = ParamsSchema.safeParse(params);
|
|
if (!parsed.success) {
|
|
return json({ error: "Invalid or missing run ID" }, { status: 400 });
|
|
}
|
|
|
|
const anyBody = await request.json();
|
|
const body = RescheduleRunRequestBody.safeParse(anyBody);
|
|
if (!body.success) {
|
|
return json({ error: "Invalid request body" }, { status: 400 });
|
|
}
|
|
|
|
const env = authenticationResult.environment;
|
|
// Pre-resolve the absolute Date the buffer snapshot should encode.
|
|
// RescheduleTaskRunService expects this to be present on the body for
|
|
// its PG-side flow; for the buffer-side patch we encode the same
|
|
// wall-clock value so the drainer's engine.trigger sees the intended
|
|
// delayUntil after materialisation.
|
|
//
|
|
// Wire-compat: pre-PR the validation lived inside
|
|
// `RescheduleTaskRunService.call` (rescheduleTaskRun.server.ts:14-18),
|
|
// which throws `ServiceValidationError("Invalid delay: …")`. The
|
|
// route's catch block below converts that to status **400** (not
|
|
// 422 — `ServiceValidationError` defaults to 422 but this route's
|
|
// catch block has always returned 400). Mirror that 400 + message
|
|
// shape here so SDK consumers keying retry/classification logic on
|
|
// 400 see no behavioural drift now that the parse is hoisted to the
|
|
// route layer.
|
|
const delayUntil = await parseDelay(body.data.delay);
|
|
if (!delayUntil) {
|
|
return json({ error: `Invalid delay: ${body.data.delay}` }, { status: 400 });
|
|
}
|
|
|
|
try {
|
|
// PG-side `RescheduleTaskRunService.call` enforces
|
|
// `taskRun.status !== "DELAYED"` and 422s otherwise — without an
|
|
// equivalent guard the buffer path would happily inject a
|
|
// `delayUntil` into the snapshot of a non-delayed buffered run, and
|
|
// the drainer would materialise it with an unintended delay. The
|
|
// SyntheticRun type doesn't carry a "DELAYED" enum value because
|
|
// it's not a terminal status the trace API needs to express; the
|
|
// buffered analogue is `delayUntil` set in the snapshot. Gate on
|
|
// that.
|
|
//
|
|
// Only apply the guard when the buffer entry is NOT yet
|
|
// materialised. Post-materialise the entry sticks around for a
|
|
// 30s grace TTL with `materialised: true`, but the PG row is now
|
|
// canonical — its DELAYED state may differ from what the snapshot
|
|
// recorded at trigger time (e.g. a prior reschedule via the PG
|
|
// path, or a delay set by the engine through another mechanism).
|
|
// Reading from the stale snapshot would 422 a legitimately-DELAYED
|
|
// PG row. When `materialised` we let `mutateWithFallback` route to
|
|
// PG, which runs its own canonical DELAYED check.
|
|
const buffer = getMollifierBuffer();
|
|
const entry = buffer ? await buffer.getEntry(parsed.data.runParam) : null;
|
|
const isLiveBuffered =
|
|
entry !== null &&
|
|
entry.materialised !== true &&
|
|
entry.envId === env.id &&
|
|
entry.orgId === env.organizationId;
|
|
if (isLiveBuffered) {
|
|
const snapshot = JSON.parse(entry.payload) as Record<string, unknown>;
|
|
const snapshotDelayUntil =
|
|
typeof snapshot.delayUntil === "string" ? snapshot.delayUntil : undefined;
|
|
if (!snapshotDelayUntil) {
|
|
return json({ error: "Cannot reschedule a run that is not delayed" }, { status: 422 });
|
|
}
|
|
}
|
|
|
|
const outcome = await mutateWithFallback<Response>({
|
|
runId: parsed.data.runParam,
|
|
environmentId: env.id,
|
|
organizationId: env.organizationId,
|
|
bufferPatch: {
|
|
type: "set_delay",
|
|
delayUntil: delayUntil.toISOString(),
|
|
},
|
|
pgMutation: async (taskRun) => {
|
|
const service = new RescheduleTaskRunService();
|
|
const updatedRun = await service.call(taskRun, body.data);
|
|
if (!updatedRun) {
|
|
return json({ error: "An unknown error occurred" }, { status: 500 });
|
|
}
|
|
|
|
const run = await ApiRetrieveRunPresenter.findRun(updatedRun.friendlyId, env);
|
|
if (!run) {
|
|
return json({ error: "Run not found" }, { status: 404 });
|
|
}
|
|
const apiVersion = getApiVersion(request);
|
|
const presenter = new ApiRetrieveRunPresenter(apiVersion);
|
|
const result = await presenter.call(run, env);
|
|
if (!result) {
|
|
return json({ error: "Run not found" }, { status: 404 });
|
|
}
|
|
return json(result);
|
|
},
|
|
// Buffered snapshot has been patched. Run it through the same
|
|
// ApiRetrieveRunPresenter the PG branch uses (it falls back to
|
|
// the buffer for the SyntheticRun lookup) so the response shape
|
|
// matches `RetrieveRunResponse` — that's what the SDK's
|
|
// `rescheduleRun` zod-validates against. Returning a stripped
|
|
// `{ id, delayUntil }` object fails the SDK schema on every
|
|
// existing SDK version.
|
|
synthesisedResponse: async () => {
|
|
const run = await ApiRetrieveRunPresenter.findRun(parsed.data.runParam, env);
|
|
if (!run) {
|
|
return json({ error: "Run not found" }, { status: 404 });
|
|
}
|
|
const apiVersion = getApiVersion(request);
|
|
const presenter = new ApiRetrieveRunPresenter(apiVersion);
|
|
const result = await presenter.call(run, env);
|
|
if (!result) {
|
|
return json({ error: "Run not found" }, { status: 404 });
|
|
}
|
|
return json(result);
|
|
},
|
|
abortSignal: getRequestAbortSignal(),
|
|
});
|
|
|
|
if (outcome.kind === "not_found") {
|
|
return json({ error: "Run not found" }, { status: 404 });
|
|
}
|
|
if (outcome.kind === "timed_out") {
|
|
return json({ error: "Run materialisation timed out" }, { status: 503 });
|
|
}
|
|
return outcome.response;
|
|
} catch (error) {
|
|
if (error instanceof ServiceValidationError) {
|
|
return json({ error: error.message }, { status: 400 });
|
|
}
|
|
logger.error("Failed to reschedule run", { error });
|
|
return json({ error: "Something went wrong, please try again." }, { status: 500 });
|
|
}
|
|
}
|