v3: Refactor attempt creation to be worker requested (#1077)

* WIP worker TaskRunAttempt creation

* Handling failing task runs that cannot create an attempt for whatever reason

* Move the visibility queue stuff into a graphile job

* Fixed task runs with unsanitized queue names

* “Borrow” the code from alerts PR to get self hosted deployments working

* Add an admin API endpoint to get info about the shared marqs queue

* Allow admins to view any project metrics

* start adding lazy attempts to prod

* lazy attempt creation for prod workers

* resurrect prod stack traces

* add exception event to failed run spans

* simplify dependency resumes

* fix typecheck

* fix merge

* fresh process for all attempts

* always try sigterm first

* stop heartbeat timeout on non-inplace replace message

* add missing ack on checkpoint creation service failure

* bypass dequeue for retries with running worker

* respect retry delays

* crash runs with invalid run status for execution

* remove debug logs

* fix nack message

* fix version locking

* fresh attempt processes in dev and prod

* improve handling of ipc timeouts

* consider checkpoint failures on cancellation

* add basic chaos monkey to checkpointer

* changeset

* control forced checkpoint simulation via env var

* fix merge

* kill old attempt processes before checkpointing

* detailed perf logging for checkpointing

* add coordinator otlp endpoint example

* improve prod run cancellation

* rename supports lazy attempts migration

* fix graceful exit

* fix retry mechanics

* clear paused state before retry

* remove checkpoint image after push

* crash worker on unrecoverable errors

* refactor unrecoverable error emit

* switch to do hosted busybox image

* increase wait for duration ipc timeout

* add changeset for misc fixes

* fix merge

* fix retry delay span runId

* fix dev retries

* improve prod worker logging

* log checkpoint sizes

* add lazy attempts catalog entries

* Fixed merge issue: use zodFetch, not wrapZodFetch

* Revert "Fixed merge issue: use zodFetch, not wrapZodFetch"

This reverts commit d137e4e1fe.

* importEnvVars uses wrapZodFetch now

* add backwards compat for retries without checkpoints

* handle more cases of unrecoverable runs

* don't kill the child process if it shouldn't be killed

---------

Co-authored-by: nicktrn <55853254+nicktrn@users.noreply.github.com>
Co-authored-by: Matt Aitken <matt@mattaitken.com>
This commit is contained in:
Eric Allam
2024-05-30 11:05:52 +01:00
committed by GitHub
parent 782d4f75ae
commit e69ffd314a
51 changed files with 3391 additions and 931 deletions
+14
View File
@@ -0,0 +1,14 @@
---
"trigger.dev": patch
"@trigger.dev/core": patch
---
- Clear paused states before retry
- Detect and handle unrecoverable worker errors
- Remove checkpoints after successful push
- Permanently switch to DO hosted busybox image
- Fix IPC timeout issue, or at least handle it more gracefully
- Handle checkpoint failures
- Basic chaos monkey for checkpoint testing
- Stack traces are back in the dashboard
- Display final errors on root span
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/core": patch
---
Improve handling of IPC timeouts and fix checkpoint cancellation after failures
+296 -50
View File
@@ -1,5 +1,6 @@
import { createServer } from "node:http"; import { createServer } from "node:http";
import { $ } from "execa"; import fs from "node:fs/promises";
import { $, type ExecaChildProcess } from "execa";
import { nanoid } from "nanoid"; import { nanoid } from "nanoid";
import { Server } from "socket.io"; import { Server } from "socket.io";
import { import {
@@ -19,6 +20,11 @@ collectDefaultMetrics();
const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8020); const HTTP_SERVER_PORT = Number(process.env.HTTP_SERVER_PORT || 8020);
const NODE_NAME = process.env.NODE_NAME || "coordinator"; const NODE_NAME = process.env.NODE_NAME || "coordinator";
const DEFAULT_RETRY_DELAY_THRESHOLD_IN_MS = 30_000; const DEFAULT_RETRY_DELAY_THRESHOLD_IN_MS = 30_000;
const CHAOS_MONKEY_ENABLED = !!process.env.CHAOS_MONKEY_ENABLED;
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
);
const REGISTRY_HOST = process.env.REGISTRY_HOST || "localhost:5000"; const REGISTRY_HOST = process.env.REGISTRY_HOST || "localhost:5000";
const CHECKPOINT_PATH = process.env.CHECKPOINT_PATH || "/checkpoints"; const CHECKPOINT_PATH = process.env.CHECKPOINT_PATH || "/checkpoints";
@@ -32,6 +38,10 @@ const SECURE_CONNECTION = ["1", "true"].includes(process.env.SECURE_CONNECTION ?
const logger = new SimpleLogger(`[${NODE_NAME}]`); const logger = new SimpleLogger(`[${NODE_NAME}]`);
if (CHAOS_MONKEY_ENABLED) {
logger.log("🍌 Chaos monkey enabled");
}
type CheckpointerInitializeReturn = { type CheckpointerInitializeReturn = {
canCheckpoint: boolean; canCheckpoint: boolean;
willSimulate: boolean; willSimulate: boolean;
@@ -49,6 +59,40 @@ type CheckpointData = {
docker: boolean; docker: boolean;
}; };
function isExecaChildProcess(maybeExeca: unknown): maybeExeca is Awaited<ExecaChildProcess> {
return typeof maybeExeca === "object" && maybeExeca !== null && "escapedCommand" in maybeExeca;
}
async function getFileSize(filePath: string): Promise<number> {
try {
const stats = await fs.stat(filePath);
return stats.size;
} catch (error) {
console.error("Error getting file size:", error);
return -1;
}
}
async function getParsedFileSize(filePath: string) {
const sizeInBytes = await getFileSize(filePath);
let message = `Size in bytes: ${sizeInBytes}`;
if (sizeInBytes > 1024 * 1024) {
const sizeInMB = (sizeInBytes / 1024 / 1024).toFixed(2);
message = `Size in MB (rounded): ${sizeInMB}`;
} else if (sizeInBytes > 1024) {
const sizeInKB = (sizeInBytes / 1024).toFixed(2);
message = `Size in KB (rounded): ${sizeInKB}`;
}
return {
path: filePath,
sizeInBytes,
message,
};
}
class Checkpointer { class Checkpointer {
#initialized = false; #initialized = false;
#canCheckpoint = false; #canCheckpoint = false;
@@ -56,6 +100,7 @@ class Checkpointer {
#logger = new SimpleLogger("[checkptr]"); #logger = new SimpleLogger("[checkptr]");
#abortControllers = new Map<string, AbortController>(); #abortControllers = new Map<string, AbortController>();
#failedCheckpoints = new Map<string, unknown>();
constructor(private opts = { forceSimulate: false }) {} constructor(private opts = { forceSimulate: false }) {}
@@ -150,7 +195,11 @@ class Checkpointer {
success: !!result, success: !!result,
}); });
return result; if (!result.success) {
return;
}
return result.checkpoint;
} }
isCheckpointing(runId: string) { isCheckpointing(runId: string) {
@@ -158,6 +207,13 @@ class Checkpointer {
} }
cancelCheckpoint(runId: string): boolean { cancelCheckpoint(runId: string): boolean {
// If the last checkpoint failed, pretend we canceled it
// This ensures tasks don't wait for external resume messages to continue
if (this.#hasFailedCheckpoint(runId)) {
this.#clearFailedCheckpoint(runId);
return true;
}
const controller = this.#abortControllers.get(runId); const controller = this.#abortControllers.get(runId);
if (!controller) { if (!controller) {
@@ -176,44 +232,58 @@ class Checkpointer {
leaveRunning = true, // This mirrors kubernetes behaviour more accurately leaveRunning = true, // This mirrors kubernetes behaviour more accurately
projectRef, projectRef,
deploymentVersion, deploymentVersion,
}: CheckpointAndPushOptions): Promise<CheckpointData | undefined> { }: CheckpointAndPushOptions): Promise<
{ success: true; checkpoint: CheckpointData } | { success: false; reason?: "CANCELED" }
> {
await this.initialize(); await this.initialize();
const options = {
runId,
leaveRunning,
projectRef,
deploymentVersion,
};
if (!this.#dockerMode && !this.#canCheckpoint) { if (!this.#dockerMode && !this.#canCheckpoint) {
this.#logger.error("No checkpoint support. Simulation requires docker."); this.#logger.error("No checkpoint support. Simulation requires docker.");
return; return { success: false };
} }
if (this.#abortControllers.has(runId)) { if (this.#abortControllers.has(runId)) {
logger.error("Checkpoint procedure already in progress", { logger.error("Checkpoint procedure already in progress", { options });
options: { return { success: false };
runId,
leaveRunning,
projectRef,
deploymentVersion,
},
});
return;
} }
// This is a new checkpoint, clear any last failure for this run
this.#clearFailedCheckpoint(runId);
const controller = new AbortController(); const controller = new AbortController();
this.#abortControllers.set(runId, controller); this.#abortControllers.set(runId, controller);
const $$ = $({ signal: controller.signal }); const $$ = $({ signal: controller.signal });
try { try {
if (CHAOS_MONKEY_ENABLED) {
console.log("🍌 Chaos monkey wreaking havoc");
const random = Math.random();
if (random < 0.33) {
// Fake long checkpoint duration
await $$`sleep 300`;
} else if (random < 0.66) {
// Fake checkpoint error
await $$`false`;
} else {
// no-op
}
}
const shortCode = nanoid(8); const shortCode = nanoid(8);
const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode); const imageRef = this.#getImageRef(projectRef, deploymentVersion, shortCode);
const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode); const exportLocation = this.#getExportLocation(projectRef, deploymentVersion, shortCode);
this.#logger.log("Checkpointing:", { this.#logger.log("Checkpointing:", { options });
options: {
runId,
leaveRunning,
projectRef,
deploymentVersion,
},
});
const containterName = this.#getRunContainerName(runId); const containterName = this.#getRunContainerName(runId);
@@ -234,9 +304,9 @@ class Checkpointer {
); );
} }
} }
} catch (error: any) { } catch (error) {
this.#logger.error(error.stderr); this.#logger.error("Failed while creating docker checkpoint", { exportLocation });
return; throw error;
} }
this.#logger.log("checkpoint created:", { this.#logger.log("checkpoint created:", {
@@ -245,8 +315,11 @@ class Checkpointer {
}); });
return { return {
location: exportLocation, success: true,
docker: true, checkpoint: {
location: exportLocation,
docker: true,
},
}; };
} }
@@ -266,54 +339,104 @@ class Checkpointer {
throw new Error("could not find container id"); throw new Error("could not find container id");
} }
const start = performance.now();
// Create checkpoint
this.#logger.debug(await $$`crictl checkpoint --export=${exportLocation} ${containerId}`); this.#logger.debug(await $$`crictl checkpoint --export=${exportLocation} ${containerId}`);
const postCheckpoint = performance.now();
// Print checkpoint size
const size = await getParsedFileSize(exportLocation);
this.#logger.log("checkpoint archive created", { size, options });
// Create image from checkpoint // Create image from checkpoint
const container = this.#logger.debug(await $$`buildah from scratch`); const container = this.#logger.debug(await $$`buildah from scratch`);
const postFrom = performance.now();
this.#logger.debug(await $$`buildah add ${container} ${exportLocation} /`); this.#logger.debug(await $$`buildah add ${container} ${exportLocation} /`);
const postAdd = performance.now();
this.#logger.debug( this.#logger.debug(
await $$`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}` await $$`buildah config --annotation=io.kubernetes.cri-o.annotations.checkpoint.name=counter ${container}`
); );
const postConfig = performance.now();
this.#logger.debug(await $$`buildah commit ${container} ${imageRef}`); this.#logger.debug(await $$`buildah commit ${container} ${imageRef}`);
const postCommit = performance.now();
this.#logger.debug(await $$`buildah rm ${container}`); this.#logger.debug(await $$`buildah rm ${container}`);
const postRm = performance.now();
// Push checkpoint image // Push checkpoint image
this.#logger.debug(await $$`buildah push --tls-verify=${REGISTRY_TLS_VERIFY} ${imageRef}`); this.#logger.debug(await $$`buildah push --tls-verify=${REGISTRY_TLS_VERIFY} ${imageRef}`);
const postPush = performance.now();
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef }); const perf = {
"crictl checkpoint": postCheckpoint - start,
"buildah from": postFrom - postCheckpoint,
"buildah add": postAdd - postFrom,
"buildah config": postConfig - postAdd,
"buildah commit": postCommit - postConfig,
"buildah rm": postRm - postCommit,
"buildah push": postPush - postRm,
};
this.#logger.log("Checkpointed and pushed image to:", { location: imageRef, perf });
try { try {
await $$`rm ${exportLocation}`; await $$`rm ${exportLocation}`;
this.#logger.log("Deleted checkpoint archive", { exportLocation }); this.#logger.log("Deleted checkpoint archive", { exportLocation });
// Disabled for now as this will increase restore time by having to pull the image again await $`buildah rmi ${imageRef}`;
// await $`buildah rmi ${imageRef}`; this.#logger.log("Deleted checkpoint image", { imageRef });
// this.#logger.log("Deleted checkpoint image", { imageRef });
} catch (error) { } catch (error) {
this.#logger.error("Failed during checkpoint cleanup", { exportLocation }); this.#logger.error("Failed during checkpoint cleanup", { exportLocation });
this.#logger.debug(error); throw error;
} }
return { return {
location: imageRef, success: true,
docker: false, checkpoint: {
location: imageRef,
docker: false,
},
}; };
} catch (error) { } catch (error) {
this.#logger.error("checkpoint failed", { if (isExecaChildProcess(error)) {
options: { if (error.isCanceled) {
runId, this.#logger.error("Checkpoint canceled", { options, error });
leaveRunning,
projectRef, return { success: false, reason: "CANCELED" };
deploymentVersion, }
},
error, // Everything that's not a cancellation is a failure
}); this.#failCheckpoint(runId, error);
return; this.#logger.error("Checkpoint command error", { options, error });
return { success: false };
}
this.#failCheckpoint(runId, error);
this.#logger.error("Unhandled checkpoint error", { options, error });
return { success: false };
} finally { } finally {
this.#abortControllers.delete(runId); this.#abortControllers.delete(runId);
} }
} }
#failCheckpoint(runId: string, error: unknown) {
this.#failedCheckpoints.set(runId, error);
}
#clearFailedCheckpoint(runId: string) {
this.#failedCheckpoints.delete(runId);
}
#hasFailedCheckpoint(runId: string) {
return this.#failedCheckpoints.has(runId);
}
#getRunContainerName(suffix: string) { #getRunContainerName(suffix: string) {
return `task-run-${suffix}`; return `task-run-${suffix}`;
} }
@@ -321,7 +444,7 @@ class Checkpointer {
class TaskCoordinator { class TaskCoordinator {
#httpServer: ReturnType<typeof createServer>; #httpServer: ReturnType<typeof createServer>;
#checkpointer = new Checkpointer({ forceSimulate: true }); #checkpointer = new Checkpointer({ forceSimulate: FORCE_CHECKPOINT_SIMULATION });
#prodWorkerNamespace: ZodNamespace< #prodWorkerNamespace: ZodNamespace<
typeof ProdWorkerToCoordinatorMessages, typeof ProdWorkerToCoordinatorMessages,
@@ -442,6 +565,28 @@ class TaskCoordinator {
taskSocket.emit("REQUEST_ATTEMPT_CANCELLATION", message); taskSocket.emit("REQUEST_ATTEMPT_CANCELLATION", message);
}, },
REQUEST_RUN_CANCELLATION: async (message) => {
const taskSocket = await this.#getRunSocket(message.runId);
if (!taskSocket) {
logger.log("Socket for run not found", {
runId: message.runId,
});
return;
}
if (message.delayInMs) {
taskSocket.emit("REQUEST_EXIT", {
version: "v2",
delayInMs: message.delayInMs,
});
} else {
// If there's no delay, assume the worker doesn't support non-v1 messages
taskSocket.emit("REQUEST_EXIT", {
version: "v1",
});
}
},
READY_FOR_RETRY: async (message) => { READY_FOR_RETRY: async (message) => {
const taskSocket = await this.#getRunSocket(message.runId); const taskSocket = await this.#getRunSocket(message.runId);
@@ -528,6 +673,20 @@ class TaskCoordinator {
onConnection: async (socket, handler, sender) => { onConnection: async (socket, handler, sender) => {
const logger = new SimpleLogger(`[prod-worker][${socket.id}]`); const logger = new SimpleLogger(`[prod-worker][${socket.id}]`);
const crashRun = async (error: { name: string; message: string; stack?: string }) => {
try {
this.#platformSocket?.send("RUN_CRASHED", {
version: "v1",
runId: socket.data.runId,
error,
});
} finally {
socket.emit("REQUEST_EXIT", {
version: "v1",
});
}
};
const checkpointInProgress = () => { const checkpointInProgress = () => {
return this.#checkpointableTasks.has(socket.data.runId); return this.#checkpointableTasks.has(socket.data.runId);
}; };
@@ -596,8 +755,9 @@ class TaskCoordinator {
if (!executionAck) { if (!executionAck) {
logger.error("no execution ack", { runId: socket.data.runId }); logger.error("no execution ack", { runId: socket.data.runId });
socket.emit("REQUEST_EXIT", { await crashRun({
version: "v1", name: "ReadyForExecutionError",
message: "No execution ack",
}); });
return; return;
@@ -606,8 +766,9 @@ class TaskCoordinator {
if (!executionAck.success) { if (!executionAck.success) {
logger.error("failed to get execution payload", { runId: socket.data.runId }); logger.error("failed to get execution payload", { runId: socket.data.runId });
socket.emit("REQUEST_EXIT", { await crashRun({
version: "v1", name: "ReadyForExecutionError",
message: "Failed to get execution payload",
}); });
return; return;
@@ -624,6 +785,46 @@ class TaskCoordinator {
} }
}); });
socket.on("READY_FOR_LAZY_ATTEMPT", async (message) => {
logger.log("[READY_FOR_LAZY_ATTEMPT]", message);
try {
const lazyAttempt = await this.#platformSocket?.sendWithAck("READY_FOR_LAZY_ATTEMPT", {
...message,
envId: socket.data.envId,
});
if (!lazyAttempt) {
logger.error("no lazy attempt ack", { runId: socket.data.runId });
await crashRun({
name: "ReadyForLazyAttemptError",
message: "No lazy attempt ack",
});
return;
}
if (!lazyAttempt.success) {
logger.error("failed to get lazy attempt payload", { runId: socket.data.runId });
await crashRun({
name: "ReadyForLazyAttemptError",
message: "Failed to get lazy attempt payload",
});
return;
}
socket.emit("EXECUTE_TASK_RUN_LAZY_ATTEMPT", {
version: "v1",
lazyPayload: lazyAttempt.lazyPayload,
});
} catch (error) {
logger.error("Error", { error });
}
});
socket.on("READY_FOR_RESUME", async (message) => { socket.on("READY_FOR_RESUME", async (message) => {
logger.log("[READY_FOR_RESUME]", message); logger.log("[READY_FOR_RESUME]", message);
@@ -714,6 +915,19 @@ class TaskCoordinator {
} }
}); });
socket.on("TASK_RUN_FAILED_TO_RUN", async ({ completion }) => {
logger.log("completed task", { completionId: completion.id });
this.#platformSocket?.send("TASK_RUN_FAILED_TO_RUN", {
version: "v1",
completion,
});
socket.emit("REQUEST_EXIT", {
version: "v1",
});
});
socket.on("READY_FOR_CHECKPOINT", async (message) => { socket.on("READY_FOR_CHECKPOINT", async (message) => {
logger.log("[READY_FOR_CHECKPOINT]", message); logger.log("[READY_FOR_CHECKPOINT]", message);
@@ -890,7 +1104,7 @@ class TaskCoordinator {
logger.log("[INDEX_TASKS]", message); logger.log("[INDEX_TASKS]", message);
const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", { const workerAck = await this.#platformSocket?.sendWithAck("CREATE_WORKER", {
version: "v1", version: "v2",
projectRef: socket.data.projectRef, projectRef: socket.data.projectRef,
envId: socket.data.envId, envId: socket.data.envId,
deploymentId: message.deploymentId, deploymentId: message.deploymentId,
@@ -899,6 +1113,7 @@ class TaskCoordinator {
packageVersion: message.packageVersion, packageVersion: message.packageVersion,
tasks: message.tasks, tasks: message.tasks,
}, },
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
}); });
if (!workerAck) { if (!workerAck) {
@@ -917,6 +1132,34 @@ class TaskCoordinator {
error: message.error, error: message.error,
}); });
}); });
socket.on("CREATE_TASK_RUN_ATTEMPT", async (message, callback) => {
logger.log("[CREATE_TASK_RUN_ATTEMPT]", message);
const createAttempt = await this.#platformSocket?.sendWithAck("CREATE_TASK_RUN_ATTEMPT", {
runId: message.runId,
envId: socket.data.envId,
});
if (!createAttempt?.success) {
logger.debug("no ack while creating attempt", message);
callback({ success: false });
return;
}
socket.data.attemptFriendlyId = createAttempt.executionPayload.execution.attempt.id;
callback({
success: true,
executionPayload: createAttempt.executionPayload,
});
});
socket.on("UNRECOVERABLE_ERROR", async (message) => {
logger.log("[UNRECOVERABLE_ERROR]", message);
await crashRun(message.error);
});
}, },
onDisconnect: async (socket, handler, sender, logger) => { onDisconnect: async (socket, handler, sender, logger) => {
this.#platformSocket?.send("LOG", { this.#platformSocket?.send("LOG", {
@@ -928,13 +1171,16 @@ class TaskCoordinator {
TASK_HEARTBEAT: async (message) => { TASK_HEARTBEAT: async (message) => {
this.#platformSocket?.send("TASK_HEARTBEAT", message); this.#platformSocket?.send("TASK_HEARTBEAT", message);
}, },
TASK_RUN_HEARTBEAT: async (message) => {
this.#platformSocket?.send("TASK_RUN_HEARTBEAT", message);
},
}, },
}); });
return provider; return provider;
} }
#cancelCheckpoint(runId: string) { #cancelCheckpoint(runId: string): boolean {
const checkpointWait = this.#checkpointableTasks.get(runId); const checkpointWait = this.#checkpointableTasks.get(runId);
if (checkpointWait) { if (checkpointWait) {
+2
View File
@@ -4,6 +4,8 @@ PLATFORM_WS_PORT=3030
PLATFORM_SECRET=provider-secret PLATFORM_SECRET=provider-secret
SECURE_CONNECTION=false SECURE_CONNECTION=false
OTEL_EXPORTER_OTLP_ENDPOINT=http://0.0.0.0:3030/otel
# Use this if you are on macOS # Use this if you are on macOS
# COORDINATOR_HOST="host.docker.internal" # COORDINATOR_HOST="host.docker.internal"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://host.docker.internal:4318" # OTEL_EXPORTER_OTLP_ENDPOINT="http://host.docker.internal:4318"
+6 -1
View File
@@ -13,9 +13,14 @@ import { PostStartCauses, PreStopCauses } from "@trigger.dev/core/v3";
const MACHINE_NAME = process.env.MACHINE_NAME || "local"; const MACHINE_NAME = process.env.MACHINE_NAME || "local";
const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020; const COORDINATOR_PORT = process.env.COORDINATOR_PORT || 8020;
const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1"; const COORDINATOR_HOST = process.env.COORDINATOR_HOST || "127.0.0.1";
const OTEL_EXPORTER_OTLP_ENDPOINT = const OTEL_EXPORTER_OTLP_ENDPOINT =
process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318"; process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://0.0.0.0:4318";
const FORCE_CHECKPOINT_SIMULATION = ["1", "true"].includes(
process.env.FORCE_CHECKPOINT_SIMULATION ?? "true"
);
const logger = new SimpleLogger(`[${MACHINE_NAME}]`); const logger = new SimpleLogger(`[${MACHINE_NAME}]`);
type InitializeReturn = { type InitializeReturn = {
@@ -278,7 +283,7 @@ class DockerTaskOperations implements TaskOperations {
} }
const provider = new ProviderShell({ const provider = new ProviderShell({
tasks: new DockerTaskOperations({ forceSimulate: true }), tasks: new DockerTaskOperations({ forceSimulate: FORCE_CHECKPOINT_SIMULATION }),
type: "docker", type: "docker",
}); });
+1 -1
View File
@@ -212,7 +212,7 @@ class KubernetesTaskOperations implements TaskOperations {
}, },
{ {
name: "populate-taskinfo", name: "populate-taskinfo",
image: "docker.io/library/busybox", image: "registry.digitalocean.com/trigger/busybox",
imagePullPolicy: "IfNotPresent", imagePullPolicy: "IfNotPresent",
command: ["/bin/sh", "-c"], command: ["/bin/sh", "-c"],
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"], args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
@@ -0,0 +1,31 @@
import { LoaderFunctionArgs, json } from "@remix-run/server-runtime";
import { prisma } from "~/db.server";
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
import { marqs } from "~/v3/marqs/index.server";
export async function loader({ request, params }: LoaderFunctionArgs) {
// Next authenticate the request
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
if (!authenticationResult) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: {
id: authenticationResult.userId,
},
});
if (!user) {
return json({ error: "Invalid or Missing API key" }, { status: 401 });
}
if (!user.admin) {
return json({ error: "You must be an admin to perform this action" }, { status: 403 });
}
const details = await marqs?.getSharedQueueDetails();
return json(details);
}
@@ -0,0 +1,45 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { CreateTaskRunAttemptService } from "~/v3/services/createTaskRunAttempt.server";
const ParamsSchema = z.object({
/* This is the run friendly ID */
runParam: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Authenticate the request
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 { runParam } = parsed.data;
const service = new CreateTaskRunAttemptService();
try {
const { execution } = await service.call(runParam, authenticationResult.environment);
return json(execution, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 422 });
}
return json(
{ error: error instanceof Error ? error.message : "Internal Server Error" },
{ status: 500 }
);
}
}
+26
View File
@@ -44,6 +44,8 @@ import { GraphileMigrationHelperService } from "./db/graphileMigrationHelper.ser
import { PerformBulkActionService } from "~/v3/services/bulk/performBulkAction.server"; import { PerformBulkActionService } from "~/v3/services/bulk/performBulkAction.server";
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server"; import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server";
import { RequeueTaskRunService } from "~/v3/requeueTaskRun.server";
import { RetryAttemptService } from "~/v3/services/retryAttempt.server";
const workerCatalog = { const workerCatalog = {
indexEndpoint: z.object({ indexEndpoint: z.object({
@@ -158,6 +160,12 @@ const workerCatalog = {
"v3.performBulkActionItem": z.object({ "v3.performBulkActionItem": z.object({
bulkActionItemId: z.string(), bulkActionItemId: z.string(),
}), }),
"v3.requeueTaskRun": z.object({
runId: z.string(),
}),
"v3.retryAttempt": z.object({
runId: z.string(),
}),
}; };
const executionWorkerCatalog = { const executionWorkerCatalog = {
@@ -600,6 +608,24 @@ function getWorkerQueue() {
await service.performBulkActionItem(payload.bulkActionItemId); await service.performBulkActionItem(payload.bulkActionItemId);
}, },
}, },
"v3.requeueTaskRun": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new RequeueTaskRunService();
await service.call(payload.runId);
},
},
"v3.retryAttempt": {
priority: 0,
maxAttempts: 3,
handler: async (payload, job) => {
const service = new RetryAttemptService();
return await service.call(payload.runId);
},
},
}, },
}); });
} }
@@ -54,7 +54,10 @@ export class AuthenticatedSocketConnection {
schema: clientWebsocketMessages, schema: clientWebsocketMessages,
messages: { messages: {
READY_FOR_TASKS: async (payload) => { READY_FOR_TASKS: async (payload) => {
await this._consumer.registerBackgroundWorker(payload.backgroundWorkerId); await this._consumer.registerBackgroundWorker(
payload.backgroundWorkerId,
payload.inProgressRuns ?? []
);
}, },
BACKGROUND_WORKER_DEPRECATED: async (payload) => { BACKGROUND_WORKER_DEPRECATED: async (payload) => {
await this._consumer.deprecateBackgroundWorker(payload.backgroundWorkerId); await this._consumer.deprecateBackgroundWorker(payload.backgroundWorkerId);
@@ -69,10 +72,22 @@ export class AuthenticatedSocketConnection {
); );
break; break;
} }
case "TASK_RUN_FAILED_TO_RUN": {
await this._consumer.taskRunFailed(
payload.backgroundWorkerId,
payload.data.completion
);
break;
}
case "TASK_HEARTBEAT": { case "TASK_HEARTBEAT": {
await this._consumer.taskHeartbeat(payload.backgroundWorkerId, payload.data.id); await this._consumer.taskHeartbeat(payload.backgroundWorkerId, payload.data.id);
break; break;
} }
case "TASK_RUN_HEARTBEAT": {
await this._consumer.taskRunHeartbeat(payload.backgroundWorkerId, payload.data.id);
break;
}
} }
}, },
}, },
+50 -8
View File
@@ -10,6 +10,7 @@ import {
SpanEvents, SpanEvents,
SpanMessagingEvent, SpanMessagingEvent,
TaskEventStyle, TaskEventStyle,
TaskRunError,
correctErrorStackTrace, correctErrorStackTrace,
createPacketAttributesAsJson, createPacketAttributesAsJson,
flattenAttributes, flattenAttributes,
@@ -117,6 +118,7 @@ export type QueriedEvent = Prisma.TaskEventGetPayload<{
isCancelled: true; isCancelled: true;
level: true; level: true;
events: true; events: true;
environmentType: true;
}; };
}>; }>;
@@ -156,6 +158,7 @@ export type SpanSummary = {
isPartial: boolean; isPartial: boolean;
isCancelled: boolean; isCancelled: boolean;
level: NonNullable<CreatableEvent["level"]>; level: NonNullable<CreatableEvent["level"]>;
environmentType: CreatableEventEnvironmentType;
}; };
}; };
@@ -165,6 +168,7 @@ export type UpdateEventOptions = {
attributes: TraceAttributes; attributes: TraceAttributes;
endTime?: Date; endTime?: Date;
immediate?: boolean; immediate?: boolean;
events?: SpanEvents;
}; };
export class EventRepository { export class EventRepository {
@@ -239,7 +243,7 @@ export class EventRepository {
isCancelled: false, isCancelled: false,
status: options?.attributes.isError ? "ERROR" : "OK", status: options?.attributes.isError ? "ERROR" : "OK",
links: event.links ?? [], links: event.links ?? [],
events: event.events ?? [], events: event.events ?? (options?.events as any) ?? [],
duration: calculateDurationFromStart(event.startTime, options?.endTime), duration: calculateDurationFromStart(event.startTime, options?.endTime),
properties: event.properties as Attributes, properties: event.properties as Attributes,
metadata: event.metadata as Attributes, metadata: event.metadata as Attributes,
@@ -386,6 +390,7 @@ export class EventRepository {
isCancelled: true, isCancelled: true,
level: true, level: true,
events: true, events: true,
environmentType: true,
}, },
where: { where: {
traceId, traceId,
@@ -421,6 +426,7 @@ export class EventRepository {
startTime: getDateFromNanoseconds(event.startTime), startTime: getDateFromNanoseconds(event.startTime),
level: event.level, level: event.level,
events: event.events, events: event.events,
environmentType: event.environmentType,
}, },
}; };
}); });
@@ -505,7 +511,11 @@ export class EventRepository {
}); });
} }
const events = transformEvents(span.data.events, fullEvent.metadata as Attributes); const events = transformEvents(
span.data.events,
fullEvent.metadata as Attributes,
traceSummary?.rootSpan.data.environmentType === "DEVELOPMENT"
);
return { return {
...fullEvent, ...fullEvent,
@@ -877,6 +887,36 @@ export function stripAttributePrefix(attributes: Attributes, prefix: string) {
return result; return result;
} }
export function createExceptionPropertiesFromError(error: TaskRunError): ExceptionEventProperties {
switch (error.type) {
case "BUILT_IN_ERROR": {
return {
type: error.name,
message: error.message,
stacktrace: error.stackTrace,
};
}
case "CUSTOM_ERROR": {
return {
type: "Error",
message: error.raw,
};
}
case "INTERNAL_ERROR": {
return {
type: "Internal error",
message: [error.code, error.message].filter(Boolean).join(": "),
};
}
case "STRING_ERROR": {
return {
type: "Error",
message: error.raw,
};
}
}
}
/** /**
* Filters out partial events from a batch of creatable events, excluding those that have a corresponding full event. * Filters out partial events from a batch of creatable events, excluding those that have a corresponding full event.
* @param batch - The batch of creatable events to filter. * @param batch - The batch of creatable events to filter.
@@ -1097,16 +1137,16 @@ function removePrivateProperties(
return result; return result;
} }
function transformEvents(events: SpanEvents, properties: Attributes): SpanEvents { function transformEvents(events: SpanEvents, properties: Attributes, isDev: boolean): SpanEvents {
return (events ?? []).map((event) => transformEvent(event, properties)); return (events ?? []).map((event) => transformEvent(event, properties, isDev));
} }
function transformEvent(event: SpanEvent, properties: Attributes): SpanEvent { function transformEvent(event: SpanEvent, properties: Attributes, isDev: boolean): SpanEvent {
if (isExceptionSpanEvent(event)) { if (isExceptionSpanEvent(event)) {
return { return {
...event, ...event,
properties: { properties: {
exception: transformException(event.properties.exception, properties), exception: transformException(event.properties.exception, properties, isDev),
}, },
}; };
} }
@@ -1116,11 +1156,12 @@ function transformEvent(event: SpanEvent, properties: Attributes): SpanEvent {
function transformException( function transformException(
exception: ExceptionEventProperties, exception: ExceptionEventProperties,
properties: Attributes properties: Attributes,
isDev: boolean
): ExceptionEventProperties { ): ExceptionEventProperties {
const projectDirAttributeValue = properties[SemanticInternalAttributes.PROJECT_DIR]; const projectDirAttributeValue = properties[SemanticInternalAttributes.PROJECT_DIR];
if (typeof projectDirAttributeValue !== "string") { if (projectDirAttributeValue !== undefined && typeof projectDirAttributeValue !== "string") {
return exception; return exception;
} }
@@ -1129,6 +1170,7 @@ function transformException(
stacktrace: exception.stacktrace stacktrace: exception.stacktrace
? correctErrorStackTrace(exception.stacktrace, projectDirAttributeValue, { ? correctErrorStackTrace(exception.stacktrace, projectDirAttributeValue, {
removeFirstLine: true, removeFirstLine: true,
isDev,
}) })
: undefined, : undefined,
}; };
@@ -0,0 +1,66 @@
import { TaskRunFailedExecutionResult } from "@trigger.dev/core/v3";
import { logger } from "~/services/logger.server";
import { marqs } from "~/v3/marqs/index.server";
import { TaskRunStatus } from "@trigger.dev/database";
import { createExceptionPropertiesFromError, eventRepository } from "./eventRepository.server";
import { BaseService } from "./services/baseService.server";
const FAILABLE_TASK_RUN_STATUSES: TaskRunStatus[] = ["EXECUTING", "PENDING", "WAITING_FOR_DEPLOY"];
export class FailedTaskRunService extends BaseService {
public async call(runFriendlyId: string, completion: TaskRunFailedExecutionResult) {
const taskRun = await this._prisma.taskRun.findUnique({
where: { friendlyId: runFriendlyId },
});
if (!taskRun) {
logger.error("[FailedTaskRunService] Task run not found", {
runFriendlyId,
completion,
});
return;
}
if (!FAILABLE_TASK_RUN_STATUSES.includes(taskRun.status)) {
logger.error("[FailedTaskRunService] Task run is not in a failable state", {
taskRun,
completion,
});
return;
}
// No more retries, we need to fail the task run
logger.debug("[FailedTaskRunService] Failing task run", { taskRun, completion });
await marqs?.acknowledgeMessage(taskRun.id);
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: new Date(),
properties: {
exception: createExceptionPropertiesFromError(completion.error),
},
},
],
});
await this._prisma.taskRun.update({
where: {
id: taskRun.id,
},
data: {
status: "SYSTEM_FAILURE",
},
});
}
}
+65 -1
View File
@@ -22,6 +22,7 @@ import { DeploymentIndexFailed } from "./services/deploymentIndexFailed.server";
import { Redis } from "ioredis"; import { Redis } from "ioredis";
import { createAdapter } from "@socket.io/redis-adapter"; import { createAdapter } from "@socket.io/redis-adapter";
import { CrashTaskRunService } from "./services/crashTaskRun.server"; import { CrashTaskRunService } from "./services/crashTaskRun.server";
import { CreateTaskRunAttemptService } from "./services/createTaskRunAttempt.server";
export const socketIo = singleton("socketIo", initalizeIoServer); export const socketIo = singleton("socketIo", initalizeIoServer);
@@ -91,6 +92,23 @@ function createCoordinatorNamespace(io: Server) {
return { success: true, payload }; return { success: true, payload };
} }
}, },
READY_FOR_LAZY_ATTEMPT: async (message) => {
try {
const payload = await sharedQueueTasks.getLazyAttemptPayload(
message.envId,
message.runId
);
if (!payload) {
logger.error("Failed to retrieve lazy attempt payload", message);
return { success: false, reason: "Failed to retrieve payload" };
}
return { success: true, lazyPayload: payload };
} catch (error) {
return { success: false };
}
},
READY_FOR_RESUME: async (message) => { READY_FOR_RESUME: async (message) => {
const resumeAttempt = new ResumeAttemptService(); const resumeAttempt = new ResumeAttemptService();
await resumeAttempt.call(message); await resumeAttempt.call(message);
@@ -103,9 +121,15 @@ function createCoordinatorNamespace(io: Server) {
checkpoint: message.checkpoint, checkpoint: message.checkpoint,
}); });
}, },
TASK_RUN_FAILED_TO_RUN: async (message) => {
await sharedQueueTasks.taskRunFailed(message.completion);
},
TASK_HEARTBEAT: async (message) => { TASK_HEARTBEAT: async (message) => {
await sharedQueueTasks.taskHeartbeat(message.attemptFriendlyId); await sharedQueueTasks.taskHeartbeat(message.attemptFriendlyId);
}, },
TASK_RUN_HEARTBEAT: async (message) => {
await sharedQueueTasks.taskRunHeartbeat(message.runId);
},
CHECKPOINT_CREATED: async (message) => { CHECKPOINT_CREATED: async (message) => {
const createCheckpoint = new CreateCheckpointService(); const createCheckpoint = new CreateCheckpointService();
await createCheckpoint.call(message); await createCheckpoint.call(message);
@@ -123,6 +147,7 @@ function createCoordinatorNamespace(io: Server) {
const worker = await service.call(message.projectRef, environment, message.deploymentId, { const worker = await service.call(message.projectRef, environment, message.deploymentId, {
localOnly: false, localOnly: false,
metadata: message.metadata, metadata: message.metadata,
supportsLazyAttempts: message.version !== "v1" && message.supportsLazyAttempts,
}); });
return { success: !!worker }; return { success: !!worker };
@@ -131,13 +156,52 @@ function createCoordinatorNamespace(io: Server) {
return { success: false }; return { success: false };
} }
}, },
CREATE_TASK_RUN_ATTEMPT: async (message) => {
try {
const environment = await findEnvironmentById(message.envId);
if (!environment) {
logger.error("Environment not found", { id: message.envId });
return { success: false, reason: "Environment not found" };
}
const service = new CreateTaskRunAttemptService();
const { attempt } = await service.call(message.runId, environment, false);
const payload = await sharedQueueTasks.getExecutionPayloadFromAttempt(attempt.id, true);
if (!payload) {
logger.error("Failed to retrieve payload after attempt creation", {
id: message.envId,
});
return { success: false, reason: "Failed to retrieve payload" };
}
return { success: true, executionPayload: payload };
} catch (error) {
logger.error("Error while creating attempt", { error });
return { success: false };
}
},
INDEXING_FAILED: async (message) => { INDEXING_FAILED: async (message) => {
try { try {
const service = new DeploymentIndexFailed(); const service = new DeploymentIndexFailed();
await service.call(message.deploymentId, message.error); await service.call(message.deploymentId, message.error);
} catch (e) { } catch (e) {
logger.error("Error while indexing", { error: e }); logger.error("Error while processing index failure", { error: e });
}
},
RUN_CRASHED: async (message) => {
try {
const service = new CrashTaskRunService();
await service.call(message.runId, {
reason: `${message.error.name}: ${message.error.message}`,
logs: message.error.stack,
});
} catch (e) {
logger.error("Error while processing run failure", { error: e });
} }
}, },
}, },
@@ -1,8 +1,10 @@
import { Context, ROOT_CONTEXT, Span, SpanKind, context, trace } from "@opentelemetry/api"; import { Context, ROOT_CONTEXT, Span, SpanKind, context, trace } from "@opentelemetry/api";
import { import {
TaskRunExecution, TaskRunExecution,
TaskRunExecutionLazyAttemptPayload,
TaskRunExecutionPayload, TaskRunExecutionPayload,
TaskRunExecutionResult, TaskRunExecutionResult,
TaskRunFailedExecutionResult,
serverWebsocketMessages, serverWebsocketMessages,
} from "@trigger.dev/core/v3"; } from "@trigger.dev/core/v3";
import { ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler"; import { ZodMessageSender } from "@trigger.dev/core/v3/zodMessageHandler";
@@ -14,16 +16,16 @@ import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server"; import { logger } from "~/services/logger.server";
import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server"; import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
import { EnvironmentVariablesRepository } from "../environmentVariables/environmentVariablesRepository.server"; import { EnvironmentVariablesRepository } from "../environmentVariables/environmentVariablesRepository.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { CancelAttemptService } from "../services/cancelAttempt.server";
import { CancelTaskRunService } from "../services/cancelTaskRun.server"; import { CancelTaskRunService } from "../services/cancelTaskRun.server";
import { CompleteAttemptService } from "../services/completeAttempt.server"; import { CompleteAttemptService } from "../services/completeAttempt.server";
import { CreateTaskRunAttemptService } from "../services/createTaskRunAttempt.server";
import { import {
SEMINTATTRS_FORCE_RECORDING, SEMINTATTRS_FORCE_RECORDING,
attributesFromAuthenticatedEnv, attributesFromAuthenticatedEnv,
tracer, tracer,
} from "../tracer.server"; } from "../tracer.server";
import { DevSubscriber, devPubSub } from "./devPubSub.server"; import { DevSubscriber, devPubSub } from "./devPubSub.server";
import { FailedTaskRunService } from "../failedTaskRun.server";
const MessageBody = z.discriminatedUnion("type", [ const MessageBody = z.discriminatedUnion("type", [
z.object({ z.object({
@@ -54,7 +56,6 @@ export class DevQueueConsumer {
private _taskSuccesses: number = 0; private _taskSuccesses: number = 0;
private _currentSpan: Span | undefined; private _currentSpan: Span | undefined;
private _endSpanInNextIteration = false; private _endSpanInNextIteration = false;
private _inProgressAttempts: Map<string, string> = new Map(); // Keys are task attempt friendly IDs, values are TaskRun ids/queue message ids
private _inProgressRuns: Map<string, string> = new Map(); // Keys are task run friendly IDs, values are TaskRun internal ids/queue message ids private _inProgressRuns: Map<string, string> = new Map(); // Keys are task run friendly IDs, values are TaskRun internal ids/queue message ids
constructor( constructor(
@@ -78,7 +79,7 @@ export class DevQueueConsumer {
this._backgroundWorkers.delete(id); this._backgroundWorkers.delete(id);
} }
public async registerBackgroundWorker(id: string) { public async registerBackgroundWorker(id: string, inProgressRuns: string[] = []) {
const backgroundWorker = await prisma.backgroundWorker.findUnique({ const backgroundWorker = await prisma.backgroundWorker.findUnique({
where: { friendlyId: id, runtimeEnvironmentId: this.env.id }, where: { friendlyId: id, runtimeEnvironmentId: this.env.id },
include: { include: {
@@ -96,7 +97,10 @@ export class DevQueueConsumer {
this._backgroundWorkers.set(backgroundWorker.id, backgroundWorker); this._backgroundWorkers.set(backgroundWorker.id, backgroundWorker);
logger.debug("Registered background worker", { backgroundWorker: backgroundWorker.id }); logger.debug("Registered background worker", {
backgroundWorker: backgroundWorker.id,
inProgressRuns,
});
const subscriber = await devPubSub.subscribe(`backgroundWorker:${backgroundWorker.id}:*`); const subscriber = await devPubSub.subscribe(`backgroundWorker:${backgroundWorker.id}:*`);
@@ -113,6 +117,10 @@ export class DevQueueConsumer {
this._backgroundWorkerSubscriber.set(backgroundWorker.id, subscriber); this._backgroundWorkerSubscriber.set(backgroundWorker.id, subscriber);
for (const runId of inProgressRuns) {
this._inProgressRuns.set(runId, runId);
}
// Start reading from the queue if we haven't already // Start reading from the queue if we haven't already
await this.#enable(); await this.#enable();
} }
@@ -122,15 +130,16 @@ export class DevQueueConsumer {
completion: TaskRunExecutionResult, completion: TaskRunExecutionResult,
execution: TaskRunExecution execution: TaskRunExecution
) { ) {
this._inProgressAttempts.delete(execution.attempt.id);
if (completion.ok) { if (completion.ok) {
this._taskSuccesses++; this._taskSuccesses++;
} else { } else {
this._taskFailures++; this._taskFailures++;
} }
logger.debug("Task run completed", { taskRunCompletion: completion, execution }); logger.debug("[DevQueueConsumer] taskAttemptCompleted()", {
taskRunCompletion: completion,
execution,
});
const service = new CompleteAttemptService(); const service = new CompleteAttemptService();
const result = await service.call({ completion, execution, env: this.env }); const result = await service.call({ completion, execution, env: this.env });
@@ -140,7 +149,24 @@ export class DevQueueConsumer {
} }
} }
public async taskRunFailed(workerId: string, completion: TaskRunFailedExecutionResult) {
this._taskFailures++;
logger.debug("[DevQueueConsumer] taskRunFailed()", { completion });
this._inProgressRuns.delete(completion.id);
const service = new FailedTaskRunService();
await service.call(completion.id, completion);
}
/**
* @deprecated Use `taskRunHeartbeat` instead
*/
public async taskHeartbeat(workerId: string, id: string, seconds: number = 60) { public async taskHeartbeat(workerId: string, id: string, seconds: number = 60) {
logger.debug("[DevQueueConsumer] taskHeartbeat()", { id, seconds });
const taskRunAttempt = await prisma.taskRunAttempt.findUnique({ const taskRunAttempt = await prisma.taskRunAttempt.findUnique({
where: { friendlyId: id }, where: { friendlyId: id },
}); });
@@ -152,6 +178,12 @@ export class DevQueueConsumer {
await marqs?.heartbeatMessage(taskRunAttempt.taskRunId, seconds); await marqs?.heartbeatMessage(taskRunAttempt.taskRunId, seconds);
} }
public async taskRunHeartbeat(workerId: string, id: string, seconds: number = 60) {
logger.debug("[DevQueueConsumer] taskRunHeartbeat()", { id, seconds });
await marqs?.heartbeatMessage(id, seconds);
}
public async stop(reason: string = "CLI disconnected") { public async stop(reason: string = "CLI disconnected") {
if (!this._enabled) { if (!this._enabled) {
return; return;
@@ -184,66 +216,23 @@ export class DevQueueConsumer {
} }
async #cancelInProgressRunsAndAttempts(reason: string) { async #cancelInProgressRunsAndAttempts(reason: string) {
const cancelAttemptService = new CancelAttemptService();
const cancelTaskRunService = new CancelTaskRunService(); const cancelTaskRunService = new CancelTaskRunService();
const cancelledAt = new Date(); const cancelledAt = new Date();
const inProgressAttempts = new Map(this._inProgressAttempts);
const inProgressRuns = new Map(this._inProgressRuns); const inProgressRuns = new Map(this._inProgressRuns);
this._inProgressAttempts.clear();
this._inProgressRuns.clear(); this._inProgressRuns.clear();
const inProgressRunsWithNoInProgressAttempts: string[] = [];
const inProgressAttemptRunIds = new Set(inProgressAttempts.values());
for (const [runId, messageId] of inProgressRuns) {
if (!inProgressAttemptRunIds.has(messageId)) {
inProgressRunsWithNoInProgressAttempts.push(messageId);
}
}
logger.debug("Cancelling in progress runs and attempts", { logger.debug("Cancelling in progress runs and attempts", {
attempts: Array.from(inProgressAttempts.keys()),
runs: Array.from(inProgressRuns.keys()), runs: Array.from(inProgressRuns.keys()),
}); });
for (const [attemptId, messageId] of inProgressAttempts) { for (const [_, runId] of inProgressRuns) {
await this.#cancelInProgressAttempt(
attemptId,
messageId,
cancelAttemptService,
cancelledAt,
reason
);
}
for (const runId of inProgressRunsWithNoInProgressAttempts) {
await this.#cancelInProgressRun(runId, cancelTaskRunService, cancelledAt, reason); await this.#cancelInProgressRun(runId, cancelTaskRunService, cancelledAt, reason);
} }
} }
async #cancelInProgressAttempt(
attemptId: string,
messageId: string,
cancelAttemptService: CancelAttemptService,
cancelledAt: Date,
reason: string
) {
logger.debug("Cancelling in progress attempt", { attemptId, messageId });
try {
await cancelAttemptService.call(attemptId, messageId, cancelledAt, reason, this.env);
} catch (e) {
logger.error("Failed to cancel in progress attempt", {
attemptId,
messageId,
error: e,
});
}
}
async #cancelInProgressRun( async #cancelInProgressRun(
runId: string, runId: string,
service: CancelTaskRunService, service: CancelTaskRunService,
@@ -252,16 +241,20 @@ export class DevQueueConsumer {
) { ) {
logger.debug("Cancelling in progress run", { runId }); logger.debug("Cancelling in progress run", { runId });
const taskRun = await prisma.taskRun.findUnique({ const taskRun = runId.startsWith("run_")
where: { id: runId }, ? await prisma.taskRun.findUnique({
}); where: { friendlyId: runId },
})
: await prisma.taskRun.findUnique({
where: { id: runId },
});
if (!taskRun) { if (!taskRun) {
return; return;
} }
try { try {
await service.call(taskRun, { reason, cancelAttempts: false, cancelledAt }); await service.call(taskRun, { reason, cancelAttempts: true, cancelledAt });
} catch (e) { } catch (e) {
logger.error("Failed to cancel in progress run", { logger.error("Failed to cancel in progress run", {
runId, runId,
@@ -474,141 +467,131 @@ export class DevQueueConsumer {
} }
if (!this._enabled) { if (!this._enabled) {
logger.debug("Dev queue consumer is disabled", { env: this.env, queueMessage: message });
await marqs?.nackMessage(message.messageId); await marqs?.nackMessage(message.messageId);
return; return;
} }
const taskRunAttempt = await prisma.taskRunAttempt.create({
data: {
number: lockedTaskRun.attempts[0] ? lockedTaskRun.attempts[0].number + 1 : 1,
friendlyId: generateFriendlyId("attempt"),
taskRunId: lockedTaskRun.id,
startedAt: new Date(),
backgroundWorkerId: backgroundTask.workerId,
backgroundWorkerTaskId: backgroundTask.id,
status: "EXECUTING" as const,
queueId: queue.id,
runtimeEnvironmentId: this.env.id,
},
});
const execution: TaskRunExecution = {
task: {
id: backgroundTask.slug,
filePath: backgroundTask.filePath,
exportName: backgroundTask.exportName,
},
attempt: {
id: taskRunAttempt.friendlyId,
number: taskRunAttempt.number,
startedAt: taskRunAttempt.startedAt ?? taskRunAttempt.createdAt,
backgroundWorkerId: backgroundWorker.id,
backgroundWorkerTaskId: backgroundTask.id,
status: "EXECUTING" as const,
},
run: {
id: lockedTaskRun.friendlyId,
payload: lockedTaskRun.payload,
payloadType: lockedTaskRun.payloadType,
context: lockedTaskRun.context,
createdAt: lockedTaskRun.createdAt,
tags: lockedTaskRun.tags.map((tag) => tag.name),
isTest: lockedTaskRun.isTest,
idempotencyKey: lockedTaskRun.idempotencyKey ?? undefined,
},
queue: {
id: queue.friendlyId,
name: queue.name,
},
environment: {
id: this.env.id,
slug: this.env.slug,
type: this.env.type,
},
organization: {
id: this.env.organization.id,
slug: this.env.organization.slug,
name: this.env.organization.title,
},
project: {
id: this.env.project.id,
ref: this.env.project.externalRef,
slug: this.env.project.slug,
name: this.env.project.name,
},
batch:
lockedTaskRun.batchItems[0] && lockedTaskRun.batchItems[0].batchTaskRun
? { id: lockedTaskRun.batchItems[0].batchTaskRun.friendlyId }
: undefined,
};
const environmentRepository = new EnvironmentVariablesRepository(); const environmentRepository = new EnvironmentVariablesRepository();
const variables = await environmentRepository.getEnvironmentVariables( const variables = await environmentRepository.getEnvironmentVariables(
this.env.project.id, this.env.project.id,
this.env.id this.env.id
); );
const payload: TaskRunExecutionPayload = { if (backgroundWorker.supportsLazyAttempts) {
execution, const payload: TaskRunExecutionLazyAttemptPayload = {
traceContext: lockedTaskRun.traceContext as Record<string, unknown>, traceContext: lockedTaskRun.traceContext as Record<string, unknown>,
environment: variables.reduce((acc: Record<string, string>, curr) => { environment: variables.reduce((acc: Record<string, string>, curr) => {
acc[curr.key] = curr.value; acc[curr.key] = curr.value;
return acc; return acc;
}, {}), }, {}),
}; runId: lockedTaskRun.friendlyId,
messageId: lockedTaskRun.id,
isTest: lockedTaskRun.isTest,
};
try { try {
// TODO: send trace context down to the CLI await this._sender.send("BACKGROUND_WORKER_MESSAGE", {
await this._sender.send("BACKGROUND_WORKER_MESSAGE", { backgroundWorkerId: backgroundWorker.friendlyId,
backgroundWorkerId: backgroundWorker.friendlyId,
data: {
type: "EXECUTE_RUNS",
payloads: [payload],
},
});
logger.debug("Saving the in progress attempt", {
taskRunAttempt: taskRunAttempt.id,
messageId: message.messageId,
});
this._inProgressAttempts.set(taskRunAttempt.friendlyId, message.messageId);
this._inProgressRuns.set(lockedTaskRun.friendlyId, message.messageId);
} catch (e) {
if (e instanceof Error) {
this._currentSpan?.recordException(e);
} else {
this._currentSpan?.recordException(new Error(String(e)));
}
this._endSpanInNextIteration = true;
// We now need to unlock the task run and delete the task run attempt
await prisma.$transaction([
prisma.taskRun.update({
where: {
id: lockedTaskRun.id,
},
data: { data: {
lockedAt: null, type: "EXECUTE_RUN_LAZY_ATTEMPT",
lockedById: null, payload,
status: "PENDING",
}, },
}), });
prisma.taskRunAttempt.delete({
where: { logger.debug("Executing the run", {
id: taskRunAttempt.id, messageId: message.messageId,
});
this._inProgressRuns.set(lockedTaskRun.friendlyId, message.messageId);
} catch (e) {
if (e instanceof Error) {
this._currentSpan?.recordException(e);
} else {
this._currentSpan?.recordException(new Error(String(e)));
}
this._endSpanInNextIteration = true;
// We now need to unlock the task run and delete the task run attempt
await prisma.$transaction([
prisma.taskRun.update({
where: {
id: lockedTaskRun.id,
},
data: {
lockedAt: null,
lockedById: null,
status: "PENDING",
},
}),
]);
this._inProgressRuns.delete(lockedTaskRun.friendlyId);
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
}
} else {
const service = new CreateTaskRunAttemptService();
const { execution } = await service.call(lockedTaskRun.friendlyId, this.env);
const payload: TaskRunExecutionPayload = {
traceContext: lockedTaskRun.traceContext as Record<string, unknown>,
environment: variables.reduce((acc: Record<string, string>, curr) => {
acc[curr.key] = curr.value;
return acc;
}, {}),
execution,
};
try {
await this._sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId: backgroundWorker.friendlyId,
data: {
type: "EXECUTE_RUNS",
payloads: [payload],
}, },
}), });
]);
this._inProgressAttempts.delete(taskRunAttempt.friendlyId); logger.debug("Executing the run", {
this._inProgressRuns.delete(lockedTaskRun.friendlyId); messageId: message.messageId,
});
// Finally we need to nack the message so it can be retried this._inProgressRuns.set(lockedTaskRun.friendlyId, message.messageId);
await marqs?.nackMessage(message.messageId); } catch (e) {
} finally { if (e instanceof Error) {
setTimeout(() => this.#doWork(), 100); this._currentSpan?.recordException(e);
} else {
this._currentSpan?.recordException(new Error(String(e)));
}
this._endSpanInNextIteration = true;
// We now need to unlock the task run and delete the task run attempt
await prisma.$transaction([
prisma.taskRun.update({
where: {
id: lockedTaskRun.id,
},
data: {
lockedAt: null,
lockedById: null,
status: "PENDING",
},
}),
]);
this._inProgressRuns.delete(lockedTaskRun.friendlyId);
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
}
} }
} }
+74 -33
View File
@@ -21,6 +21,7 @@ import {
QueueCapacities, QueueCapacities,
QueueRange, QueueRange,
} from "./types"; } from "./types";
import { RequeueTaskRunService } from "../requeueTaskRun.server";
const tracer = trace.getTracer("marqs"); const tracer = trace.getTracer("marqs");
@@ -259,6 +260,11 @@ export class MarQS {
}); });
} }
await RequeueTaskRunService.enqueue(
messageData.messageId,
new Date(Date.now() + this.visibilityTimeoutInMs)
);
return message; return message;
}, },
{ {
@@ -272,6 +278,35 @@ export class MarQS {
); );
} }
public async getSharedQueueDetails() {
const parentQueue = constants.SHARED_QUEUE;
const { range, selectionId } = await this.queuePriorityStrategy.nextCandidateSelection(
parentQueue
);
const queues = await this.#getChildQueuesWithScores(parentQueue, range);
const queuesWithScores = await this.#calculateQueueScores(queues, (queue) =>
this.#calculateMessageQueueCapacities(queue)
);
// We need to priority shuffle here to ensure all workers aren't just working on the highest priority queue
const choice = this.queuePriorityStrategy.chooseQueue(
queuesWithScores,
parentQueue,
selectionId
);
return {
selectionId,
queues,
queuesWithScores,
nextRange: range,
queueCount: queues.length,
queueChoice: choice,
};
}
/** /**
* Dequeue a message from the shared queue (this should be used in production environments) * Dequeue a message from the shared queue (this should be used in production environments)
*/ */
@@ -350,6 +385,8 @@ export class MarQS {
[SemanticAttributes.PARENT_QUEUE]: message.parentQueue, [SemanticAttributes.PARENT_QUEUE]: message.parentQueue,
}); });
await RequeueTaskRunService.dequeue(messageId);
await this.#callAcknowledgeMessage({ await this.#callAcknowledgeMessage({
parentQueue: message.parentQueue, parentQueue: message.parentQueue,
messageKey: this.keys.messageKey(messageId), messageKey: this.keys.messageKey(messageId),
@@ -415,6 +452,8 @@ export class MarQS {
return; return;
} }
await RequeueTaskRunService.dequeue(messageId);
await this.#callAcknowledgeMessage({ await this.#callAcknowledgeMessage({
parentQueue: oldMessage.parentQueue, parentQueue: oldMessage.parentQueue,
messageKey: this.keys.messageKey(messageId), messageKey: this.keys.messageKey(messageId),
@@ -481,6 +520,8 @@ export class MarQS {
[SemanticAttributes.PARENT_QUEUE]: message.parentQueue, [SemanticAttributes.PARENT_QUEUE]: message.parentQueue,
}); });
await RequeueTaskRunService.dequeue(messageId);
await this.#callNackMessage({ await this.#callNackMessage({
messageKey: this.keys.messageKey(messageId), messageKey: this.keys.messageKey(messageId),
messageQueue: message.queue, messageQueue: message.queue,
@@ -506,16 +547,19 @@ export class MarQS {
// This should increment by the number of seconds, but with a max value of Date.now() + visibilityTimeoutInMs // This should increment by the number of seconds, but with a max value of Date.now() + visibilityTimeoutInMs
public async heartbeatMessage(messageId: string, seconds: number = 30) { public async heartbeatMessage(messageId: string, seconds: number = 30) {
// We are still calling this for backwards compatibility, but we should be using the v3.requeueTaskRun job
await this.#callHeartbeatMessage({ await this.#callHeartbeatMessage({
visibilityQueue: constants.MESSAGE_VISIBILITY_TIMEOUT_QUEUE, visibilityQueue: constants.MESSAGE_VISIBILITY_TIMEOUT_QUEUE,
messageId, messageId,
milliseconds: seconds * 1000, milliseconds: seconds * 1000,
maxVisibilityTimeout: Date.now() + this.visibilityTimeoutInMs, maxVisibilityTimeout: Date.now() + this.visibilityTimeoutInMs,
}); });
await RequeueTaskRunService.enqueue(messageId, new Date(Date.now() + seconds * 1000));
} }
get visibilityTimeoutInMs() { get visibilityTimeoutInMs() {
return this.options.visibilityTimeoutInMs ?? 300000; return this.options.visibilityTimeoutInMs ?? 300000; // 5 minutes
} }
async readMessage(messageId: string) { async readMessage(messageId: string) {
@@ -873,7 +917,6 @@ export class MarQS {
const result = await this.redis.dequeueMessage( const result = await this.redis.dequeueMessage(
messageQueue, messageQueue,
parentQueue, parentQueue,
visibilityQueue,
concurrencyLimitKey, concurrencyLimitKey,
envConcurrencyLimitKey, envConcurrencyLimitKey,
orgConcurrencyLimitKey, orgConcurrencyLimitKey,
@@ -881,7 +924,6 @@ export class MarQS {
envCurrentConcurrencyKey, envCurrentConcurrencyKey,
orgCurrentConcurrencyKey, orgCurrentConcurrencyKey,
messageQueue, messageQueue,
String(this.options.visibilityTimeoutInMs ?? 300000), // 5 minutes
String(Date.now()), String(Date.now()),
String(this.options.defaultEnvConcurrency), String(this.options.defaultEnvConcurrency),
String(this.options.defaultOrgConcurrency) String(this.options.defaultOrgConcurrency)
@@ -1007,6 +1049,9 @@ export class MarQS {
); );
} }
/**
* @deprecated This is being replaced by the v3.requeueTaskRun graphile worker job
*/
#callHeartbeatMessage({ #callHeartbeatMessage({
visibilityQueue, visibilityQueue,
messageId, messageId,
@@ -1145,25 +1190,23 @@ end
}); });
this.redis.defineCommand("dequeueMessage", { this.redis.defineCommand("dequeueMessage", {
numberOfKeys: 9, numberOfKeys: 8,
lua: ` lua: `
-- Keys: childQueue, parentQueue, visibilityQueue, concurrencyLimitKey, envConcurrencyLimitKey, orgConcurrencyLimitKey, currentConcurrencyKey, envCurrentConcurrencyKey, orgCurrentConcurrencyKey -- Keys: childQueue, parentQueue, concurrencyLimitKey, envConcurrencyLimitKey, orgConcurrencyLimitKey, currentConcurrencyKey, envCurrentConcurrencyKey, orgCurrentConcurrencyKey
local childQueue = KEYS[1] local childQueue = KEYS[1]
local parentQueue = KEYS[2] local parentQueue = KEYS[2]
local visibilityQueue = KEYS[3] local concurrencyLimitKey = KEYS[3]
local concurrencyLimitKey = KEYS[4] local envConcurrencyLimitKey = KEYS[4]
local envConcurrencyLimitKey = KEYS[5] local orgConcurrencyLimitKey = KEYS[5]
local orgConcurrencyLimitKey = KEYS[6] local currentConcurrencyKey = KEYS[6]
local currentConcurrencyKey = KEYS[7] local envCurrentConcurrencyKey = KEYS[7]
local envCurrentConcurrencyKey = KEYS[8] local orgCurrentConcurrencyKey = KEYS[8]
local orgCurrentConcurrencyKey = KEYS[9]
-- Args: childQueueName, visibilityQueue, currentTime, defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit -- Args: childQueueName, currentTime, defaultEnvConcurrencyLimit, defaultOrgConcurrencyLimit
local childQueueName = ARGV[1] local childQueueName = ARGV[1]
local visibilityTimeout = tonumber(ARGV[2]) local currentTime = tonumber(ARGV[2])
local currentTime = tonumber(ARGV[3]) local defaultEnvConcurrencyLimit = ARGV[3]
local defaultEnvConcurrencyLimit = ARGV[4] local defaultOrgConcurrencyLimit = ARGV[4]
local defaultOrgConcurrencyLimit = ARGV[5]
-- Check current org concurrency against the limit -- Check current org concurrency against the limit
local orgCurrentConcurrency = tonumber(redis.call('SCARD', orgCurrentConcurrencyKey) or '0') local orgCurrentConcurrency = tonumber(redis.call('SCARD', orgCurrentConcurrencyKey) or '0')
@@ -1199,11 +1242,9 @@ end
local messageId = messages[1] local messageId = messages[1]
local messageScore = tonumber(messages[2]) local messageScore = tonumber(messages[2])
local timeoutScore = currentTime + visibilityTimeout
-- Move message to timeout queue and update concurrency -- Move message to timeout queue and update concurrency
redis.call('ZREM', childQueue, messageId) redis.call('ZREM', childQueue, messageId)
redis.call('ZADD', visibilityQueue, timeoutScore, messageId)
redis.call('SADD', currentConcurrencyKey, messageId) redis.call('SADD', currentConcurrencyKey, messageId)
redis.call('SADD', envCurrentConcurrencyKey, messageId) redis.call('SADD', envCurrentConcurrencyKey, messageId)
redis.call('SADD', orgCurrentConcurrencyKey, messageId) redis.call('SADD', orgCurrentConcurrencyKey, messageId)
@@ -1269,7 +1310,7 @@ else
redis.call('ZADD', parentQueue, earliestMessage[2], messageQueueName) redis.call('ZADD', parentQueue, earliestMessage[2], messageQueueName)
end end
-- Remove the message from the timeout queue -- Remove the message from the timeout queue (deprecated, will eventually remove this)
redis.call('ZREM', visibilityQueue, messageId) redis.call('ZREM', visibilityQueue, messageId)
-- Update the concurrency keys -- Update the concurrency keys
@@ -1297,20 +1338,18 @@ local messageId = ARGV[2]
local currentTime = tonumber(ARGV[3]) local currentTime = tonumber(ARGV[3])
local messageScore = tonumber(ARGV[4]) local messageScore = tonumber(ARGV[4])
-- Check to see if the message is still in the visibilityQueue
local messageVisibility = tonumber(redis.call('ZSCORE', visibilityQueue, messageId)) or 0
if messageVisibility == 0 then
return
end
-- Update the concurrency keys -- Update the concurrency keys
redis.call('SREM', concurrencyKey, messageId) redis.call('SREM', concurrencyKey, messageId)
redis.call('SREM', envConcurrencyKey, messageId) redis.call('SREM', envConcurrencyKey, messageId)
redis.call('SREM', orgConcurrencyKey, messageId) redis.call('SREM', orgConcurrencyKey, messageId)
-- Remove the message from the timeout queue -- Check to see if the message is still in the visibilityQueue
redis.call('ZREM', visibilityQueue, messageId) local messageVisibility = tonumber(redis.call('ZSCORE', visibilityQueue, messageId)) or 0
if messageVisibility > 0 then
-- Remove the message from the timeout queue (deprecated, will eventually remove this)
redis.call('ZREM', visibilityQueue, messageId)
end
-- Enqueue the message into the queue -- Enqueue the message into the queue
redis.call('ZADD', childQueueKey, messageScore, messageId) redis.call('ZADD', childQueueKey, messageScore, messageId)
@@ -1337,12 +1376,16 @@ local milliseconds = tonumber(ARGV[2])
local maxVisibilityTimeout = tonumber(ARGV[3]) local maxVisibilityTimeout = tonumber(ARGV[3])
-- Get the current visibility timeout -- Get the current visibility timeout
local currentVisibilityTimeout = tonumber(redis.call('ZSCORE', visibilityQueue, messageId)) or 0 local zscoreResult = redis.call('ZSCORE', visibilityQueue, messageId)
if currentVisibilityTimeout == 0 then -- If there's no currentVisibilityTimeout, return and do not execute ZADD
if zscoreResult == false then
return return
end end
local currentVisibilityTimeout = tonumber(zscoreResult)
-- Calculate the new visibility timeout -- Calculate the new visibility timeout
local newVisibilityTimeout = math.min(currentVisibilityTimeout + milliseconds * 1000, maxVisibilityTimeout) local newVisibilityTimeout = math.min(currentVisibilityTimeout + milliseconds * 1000, maxVisibilityTimeout)
@@ -1445,7 +1488,6 @@ declare module "ioredis" {
dequeueMessage( dequeueMessage(
childQueue: string, childQueue: string,
parentQueue: string, parentQueue: string,
visibilityQueue: string,
concurrencyLimitKey: string, concurrencyLimitKey: string,
envConcurrencyLimitKey: string, envConcurrencyLimitKey: string,
orgConcurrencyLimitKey: string, orgConcurrencyLimitKey: string,
@@ -1453,7 +1495,6 @@ declare module "ioredis" {
envCurrentConcurrencyKey: string, envCurrentConcurrencyKey: string,
orgCurrentConcurrencyKey: string, orgCurrentConcurrencyKey: string,
childQueueName: string, childQueueName: string,
visibilityTimeout: string,
currentTime: string, currentTime: string,
defaultEnvConcurrencyLimit: string, defaultEnvConcurrencyLimit: string,
defaultOrgConcurrencyLimit: string, defaultOrgConcurrencyLimit: string,
@@ -5,6 +5,7 @@ import {
ProdTaskRunExecutionPayload, ProdTaskRunExecutionPayload,
TaskRunError, TaskRunError,
TaskRunExecution, TaskRunExecution,
TaskRunExecutionLazyAttemptPayload,
TaskRunExecutionResult, TaskRunExecutionResult,
TaskRunFailedExecutionResult, TaskRunFailedExecutionResult,
TaskRunSuccessfulExecutionResult, TaskRunSuccessfulExecutionResult,
@@ -33,6 +34,9 @@ import {
import { RestoreCheckpointService } from "../services/restoreCheckpoint.server"; import { RestoreCheckpointService } from "../services/restoreCheckpoint.server";
import { SEMINTATTRS_FORCE_RECORDING, tracer } from "../tracer.server"; import { SEMINTATTRS_FORCE_RECORDING, tracer } from "../tracer.server";
import { CrashTaskRunService } from "../services/crashTaskRun.server"; import { CrashTaskRunService } from "../services/crashTaskRun.server";
import { FailedTaskRunService } from "../failedTaskRun.server";
import { CreateTaskRunAttemptService } from "../services/createTaskRunAttempt.server";
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
const WithTraceContext = z.object({ const WithTraceContext = z.object({
traceparent: z.string().optional(), traceparent: z.string().optional(),
@@ -260,6 +264,14 @@ export class SharedQueueConsumer {
where: { where: {
id: message.messageId, id: message.messageId,
}, },
include: {
lockedToVersion: {
include: {
deployment: true,
tasks: true,
},
},
},
}); });
if (!existingTaskRun) { if (!existingTaskRun) {
@@ -291,7 +303,7 @@ export class SharedQueueConsumer {
(!retryingFromCheckpoint && (!retryingFromCheckpoint &&
!EXECUTABLE_RUN_STATUSES.withoutCheckpoint.includes(existingTaskRun.status)) !EXECUTABLE_RUN_STATUSES.withoutCheckpoint.includes(existingTaskRun.status))
) { ) {
logger.debug("Task run has invalid status for execution", { logger.error("Task run has invalid status for execution", {
queueMessage: message.data, queueMessage: message.data,
messageId: message.messageId, messageId: message.messageId,
taskRun: existingTaskRun.id, taskRun: existingTaskRun.id,
@@ -299,6 +311,12 @@ export class SharedQueueConsumer {
retryingFromCheckpoint, retryingFromCheckpoint,
}); });
const service = new CrashTaskRunService();
await service.call(existingTaskRun.id, {
crashAttempts: true,
reason: `Invalid run status for execution: ${existingTaskRun.status}`,
});
await this.#ackAndDoMoreWork(message.messageId); await this.#ackAndDoMoreWork(message.messageId);
return; return;
} }
@@ -398,6 +416,7 @@ export class SharedQueueConsumer {
createdAt: "desc", createdAt: "desc",
}, },
}, },
lockedBy: true,
}, },
}); });
@@ -443,39 +462,12 @@ export class SharedQueueConsumer {
return; return;
} }
const taskRunAttempt = await prisma.taskRunAttempt.create({ const nextAttemptNumber = lockedTaskRun.attempts[0]
data: { ? lockedTaskRun.attempts[0].number + 1
number: lockedTaskRun.attempts[0] ? lockedTaskRun.attempts[0].number + 1 : 1, : 1;
friendlyId: generateFriendlyId("attempt"),
taskRunId: lockedTaskRun.id,
startedAt: new Date(),
backgroundWorkerId: backgroundTask.workerId,
backgroundWorkerTaskId: backgroundTask.id,
status: "PENDING" as const,
queueId: queue.id,
runtimeEnvironmentId: lockedTaskRun.runtimeEnvironmentId,
},
include: {
backgroundWorkerTask: true,
},
});
const isRetry = taskRunAttempt.number > 1; const isRetry = nextAttemptNumber > 1;
const { machineConfig } = taskRunAttempt.backgroundWorkerTask;
const machine = Machine.safeParse(machineConfig ?? {});
if (!machine.success) {
logger.error("Failed to parse machine config", {
queueMessage: message.data,
messageId: message.messageId,
attemptId: taskRunAttempt.id,
machineConfig,
});
await this.#ackAndDoMoreWork(message.messageId);
return;
}
try { try {
if (messageBody.data.checkpointEventId) { if (messageBody.data.checkpointEventId) {
const restoreService = new RestoreCheckpointService(); const restoreService = new RestoreCheckpointService();
@@ -494,12 +486,35 @@ export class SharedQueueConsumer {
await this.#ackAndDoMoreWork(message.messageId); await this.#ackAndDoMoreWork(message.messageId);
return; return;
} }
} else if (isRetry) {
break;
}
if (!deployment.worker.supportsLazyAttempts) {
const service = new CreateTaskRunAttemptService();
await service.call(lockedTaskRun.friendlyId, undefined, false);
}
if (isRetry) {
socketIo.coordinatorNamespace.emit("READY_FOR_RETRY", { socketIo.coordinatorNamespace.emit("READY_FOR_RETRY", {
version: "v1", version: "v1",
runId: taskRunAttempt.taskRunId, runId: lockedTaskRun.id,
}); });
} else { } else {
const machineConfig = lockedTaskRun.lockedBy?.machineConfig;
const machine = Machine.safeParse(machineConfig ?? {});
if (!machine.success) {
logger.error("Failed to parse machine config", {
queueMessage: message.data,
messageId: message.messageId,
machineConfig,
});
await this.#ackAndDoMoreWork(message.messageId);
return;
}
await this._sender.send("BACKGROUND_WORKER_MESSAGE", { await this._sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId: deployment.worker.friendlyId, backgroundWorkerId: deployment.worker.friendlyId,
data: { data: {
@@ -508,12 +523,12 @@ export class SharedQueueConsumer {
version: deployment.version, version: deployment.version,
machine: machine.data, machine: machine.data,
// identifiers // identifiers
id: taskRunAttempt.id, id: "placeholder", // TODO: Remove this completely in a future release
envId: lockedTaskRun.runtimeEnvironment.id, envId: lockedTaskRun.runtimeEnvironment.id,
envType: lockedTaskRun.runtimeEnvironment.type, envType: lockedTaskRun.runtimeEnvironment.type,
orgId: lockedTaskRun.runtimeEnvironment.organizationId, orgId: lockedTaskRun.runtimeEnvironment.organizationId,
projectId: lockedTaskRun.runtimeEnvironment.projectId, projectId: lockedTaskRun.runtimeEnvironment.projectId,
runId: taskRunAttempt.taskRunId, runId: lockedTaskRun.id,
}, },
}); });
} }
@@ -535,11 +550,7 @@ export class SharedQueueConsumer {
data: { data: {
lockedAt: null, lockedAt: null,
lockedById: null, lockedById: null,
}, status: lockedTaskRun.status,
}),
prisma.taskRunAttempt.delete({
where: {
id: taskRunAttempt.id,
}, },
}), }),
]); ]);
@@ -1096,7 +1107,50 @@ class SharedQueueTasks {
return this.getExecutionPayloadFromAttempt(latestAttempt.id, setToExecuting, isRetrying); return this.getExecutionPayloadFromAttempt(latestAttempt.id, setToExecuting, isRetrying);
} }
async getLazyAttemptPayload(
envId: string,
runId: string
): Promise<TaskRunExecutionLazyAttemptPayload | undefined> {
const environment = await findEnvironmentById(envId);
if (!environment) {
logger.error("Environment not found", { id: envId });
return;
}
const run = await prisma.taskRun.findUnique({
where: {
id: runId,
runtimeEnvironmentId: environment.id,
},
});
if (!run) {
logger.error("Run not found", { id: runId, envId });
return;
}
const environmentRepository = new EnvironmentVariablesRepository();
const variables = await environmentRepository.getEnvironmentVariables(
environment.projectId,
environment.id
);
return {
traceContext: run.traceContext as Record<string, unknown>,
environment: variables.reduce((acc: Record<string, string>, curr) => {
acc[curr.key] = curr.value;
return acc;
}, {}),
runId: run.friendlyId,
messageId: run.id,
isTest: run.isTest,
} satisfies TaskRunExecutionLazyAttemptPayload;
}
async taskHeartbeat(attemptFriendlyId: string, seconds: number = 60) { async taskHeartbeat(attemptFriendlyId: string, seconds: number = 60) {
logger.debug("[SharedQueueConsumer] taskHeartbeat()", { id: attemptFriendlyId, seconds });
const taskRunAttempt = await prisma.taskRunAttempt.findUnique({ const taskRunAttempt = await prisma.taskRunAttempt.findUnique({
where: { friendlyId: attemptFriendlyId }, where: { friendlyId: attemptFriendlyId },
}); });
@@ -1107,6 +1161,20 @@ class SharedQueueTasks {
await marqs?.heartbeatMessage(taskRunAttempt.taskRunId, seconds); await marqs?.heartbeatMessage(taskRunAttempt.taskRunId, seconds);
} }
async taskRunHeartbeat(runId: string, seconds: number = 60) {
logger.debug("[SharedQueueConsumer] taskRunHeartbeat()", { runId, seconds });
await marqs?.heartbeatMessage(runId, seconds);
}
public async taskRunFailed(completion: TaskRunFailedExecutionResult) {
logger.debug("[SharedQueueConsumer] taskRunFailed()", { completion });
const service = new FailedTaskRunService();
await service.call(completion.id, completion);
}
} }
export const sharedQueueTasks = singleton("sharedQueueTasks", () => new SharedQueueTasks()); export const sharedQueueTasks = singleton("sharedQueueTasks", () => new SharedQueueTasks());
@@ -0,0 +1,95 @@
import { logger } from "~/services/logger.server";
import { marqs } from "~/v3/marqs/index.server";
import assertNever from "assert-never";
import { FailedTaskRunService } from "./failedTaskRun.server";
import { BaseService } from "./services/baseService.server";
import { PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
export class RequeueTaskRunService extends BaseService {
public async call(runId: string) {
const taskRun = await this._prisma.taskRun.findUnique({
where: { id: runId },
});
if (!taskRun) {
logger.error("[RequeueTaskRunService] Task run not found", {
runId,
});
return;
}
switch (taskRun.status) {
case "PENDING": {
logger.debug("[RequeueTaskRunService] Requeueing task run", { taskRun });
await marqs?.nackMessage(taskRun.id);
break;
}
case "EXECUTING":
case "RETRYING_AFTER_FAILURE": {
logger.debug("[RequeueTaskRunService] Failing task run", { taskRun });
const service = new FailedTaskRunService();
await service.call(taskRun.friendlyId, {
ok: false,
id: taskRun.friendlyId,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: "TASK_RUN_HEARTBEAT_TIMEOUT",
message: "Did not receive a heartbeat from the worker in time",
},
});
break;
}
case "WAITING_FOR_DEPLOY": {
logger.debug("[RequeueTaskRunService] Removing task run from queue", { taskRun });
await marqs?.acknowledgeMessage(taskRun.id);
break;
}
case "WAITING_TO_RESUME":
case "PAUSED": {
logger.debug("[RequeueTaskRunService] Requeueing task run", { taskRun });
await marqs?.nackMessage(taskRun.id);
break;
}
case "SYSTEM_FAILURE":
case "INTERRUPTED":
case "CRASHED":
case "COMPLETED_WITH_ERRORS":
case "COMPLETED_SUCCESSFULLY":
case "CANCELED": {
logger.debug("[RequeueTaskRunService] Task run is completed", { taskRun });
await marqs?.acknowledgeMessage(taskRun.id);
break;
}
default: {
assertNever(taskRun.status);
}
}
}
public static async enqueue(runId: string, runAt?: Date, tx?: PrismaClientOrTransaction) {
return await workerQueue.enqueue(
"v3.requeueTaskRun",
{ runId },
{ runAt, jobKey: `requeueTaskRun:${runId}` }
);
}
public static async dequeue(runId: string, tx?: PrismaClientOrTransaction) {
return await workerQueue.dequeue(`requeueTaskRun:${runId}`, { tx });
}
}
@@ -34,7 +34,7 @@ export abstract class BaseService {
} }
export class ServiceValidationError extends Error { export class ServiceValidationError extends Error {
constructor(message: string) { constructor(message: string, public status?: number) {
super(message); super(message);
this.name = "ServiceValidationError"; this.name = "ServiceValidationError";
} }
@@ -24,9 +24,15 @@ const CANCELLABLE_ATTEMPT_STATUSES: Array<TaskRunAttemptStatus> = [
"PENDING", "PENDING",
]; ];
type ExtendedTaskRunAttempt = Prisma.TaskRunAttemptGetPayload<{ type ExtendedTaskRun = Prisma.TaskRunGetPayload<{
include: { include: {
runtimeEnvironment: true; runtimeEnvironment: true;
lockedToVersion: true;
};
}>;
type ExtendedTaskRunAttempt = Prisma.TaskRunAttemptGetPayload<{
include: {
backgroundWorker: true; backgroundWorker: true;
}; };
}>; }>;
@@ -71,11 +77,10 @@ export class CancelTaskRunService extends BaseService {
}, },
include: { include: {
backgroundWorker: true, backgroundWorker: true,
runtimeEnvironment: true,
}, },
}, },
dependency: true,
runtimeEnvironment: true, runtimeEnvironment: true,
lockedToVersion: true,
}, },
}); });
@@ -96,6 +101,7 @@ export class CancelTaskRunService extends BaseService {
// Cancel any in progress attempts // Cancel any in progress attempts
if (opts.cancelAttempts) { if (opts.cancelAttempts) {
await this.#cancelPotentiallyRunningAttempts(cancelledTaskRun, cancelledTaskRun.attempts); await this.#cancelPotentiallyRunningAttempts(cancelledTaskRun, cancelledTaskRun.attempts);
await this.#cancelRemainingRunWorkers(cancelledTaskRun);
} }
return { return {
@@ -103,9 +109,12 @@ export class CancelTaskRunService extends BaseService {
}; };
} }
async #cancelPotentiallyRunningAttempts(run: TaskRun, attempts: ExtendedTaskRunAttempt[]) { async #cancelPotentiallyRunningAttempts(
run: ExtendedTaskRun,
attempts: ExtendedTaskRunAttempt[]
) {
for (const attempt of attempts) { for (const attempt of attempts) {
if (attempt.runtimeEnvironment.type === "DEVELOPMENT") { if (run.runtimeEnvironment.type === "DEVELOPMENT") {
// Signal the task run attempt to stop // Signal the task run attempt to stop
await devPubSub.publish( await devPubSub.publish(
`backgroundWorker:${attempt.backgroundWorkerId}:${attempt.id}`, `backgroundWorker:${attempt.backgroundWorkerId}:${attempt.id}`,
@@ -158,4 +167,19 @@ export class CancelTaskRunService extends BaseService {
} }
} }
} }
async #cancelRemainingRunWorkers(run: ExtendedTaskRun) {
if (run.runtimeEnvironment.type === "DEVELOPMENT") {
// Nothing to do
return;
}
// Broadcast cancel message to all coordinators
socketIo.coordinatorNamespace.emit("REQUEST_RUN_CANCELLATION", {
version: "v1",
runId: run.id,
// Give the attempts some time to exit gracefully. If the runs supports lazy attempts, it also supports exit delays.
delayInMs: run.lockedToVersion?.supportsLazyAttempts ? 5_000 : undefined,
});
}
} }
@@ -11,7 +11,7 @@ import { PrismaClientOrTransaction } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server"; import { logger } from "~/services/logger.server";
import { safeJsonParse } from "~/utils/json"; import { safeJsonParse } from "~/utils/json";
import { eventRepository } from "../eventRepository.server"; import { createExceptionPropertiesFromError, eventRepository } from "../eventRepository.server";
import { marqs } from "~/v3/marqs/index.server"; import { marqs } from "~/v3/marqs/index.server";
import { BaseService } from "./baseService.server"; import { BaseService } from "./baseService.server";
import { CancelAttemptService } from "./cancelAttempt.server"; import { CancelAttemptService } from "./cancelAttempt.server";
@@ -20,6 +20,7 @@ import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
import { CreateCheckpointService } from "./createCheckpoint.server"; import { CreateCheckpointService } from "./createCheckpoint.server";
import { TaskRun } from "@trigger.dev/database"; import { TaskRun } from "@trigger.dev/database";
import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server"; import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server";
import { RetryAttemptService } from "./retryAttempt.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>; type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
@@ -57,6 +58,8 @@ export class CompleteAttemptService extends BaseService {
}, },
}); });
// No attempt, so there's no message to ACK
return "COMPLETED"; return "COMPLETED";
} }
@@ -143,6 +146,8 @@ export class CompleteAttemptService extends BaseService {
env env
); );
// The cancel service handles ACK
return "COMPLETED"; return "COMPLETED";
} }
@@ -173,7 +178,7 @@ export class CompleteAttemptService extends BaseService {
properties: { properties: {
retryAt: retryAt.toISOString(), retryAt: retryAt.toISOString(),
}, },
runId: taskRunAttempt.taskRunId, runId: taskRunAttempt.taskRun.friendlyId,
style: { style: {
icon: "schedule-attempt", icon: "schedule-attempt",
}, },
@@ -185,7 +190,10 @@ export class CompleteAttemptService extends BaseService {
endTime: retryAt, endTime: retryAt,
}); });
logger.debug("Retrying", { taskRun: taskRunAttempt.taskRun.friendlyId }); logger.debug("Retrying", {
taskRun: taskRunAttempt.taskRun.friendlyId,
retry: completion.retry,
});
await this._prisma.taskRun.update({ await this._prisma.taskRun.update({
where: { where: {
@@ -203,7 +211,12 @@ export class CompleteAttemptService extends BaseService {
} }
if (!checkpoint) { if (!checkpoint) {
await this.#enqueueRetry(taskRunAttempt.taskRun, completion.retry.timestamp); await this.#retryAttempt(
taskRunAttempt.taskRun,
completion.retry.timestamp,
undefined,
taskRunAttempt.backgroundWorker.supportsLazyAttempts
);
return "RETRIED"; return "RETRIED";
} }
@@ -231,10 +244,12 @@ export class CompleteAttemptService extends BaseService {
}, },
}); });
await marqs?.acknowledgeMessage(taskRunAttempt.taskRunId);
return "COMPLETED"; return "COMPLETED";
} }
await this.#enqueueRetry( await this.#retryAttempt(
taskRunAttempt.taskRun, taskRunAttempt.taskRun,
completion.retry.timestamp, completion.retry.timestamp,
checkpointCreateResult.event.id checkpointCreateResult.event.id
@@ -253,6 +268,15 @@ export class CompleteAttemptService extends BaseService {
attributes: { attributes: {
isError: true, isError: true,
}, },
events: [
{
name: "exception",
time: new Date(),
properties: {
exception: createExceptionPropertiesFromError(completion.error),
},
},
],
}); });
if ( if (
@@ -310,17 +334,28 @@ export class CompleteAttemptService extends BaseService {
} }
} }
async #enqueueRetry(run: TaskRun, retryTimestamp: number, checkpointEventId?: string) { async #retryAttempt(
// We have to replace a potential RESUME with EXECUTE to correctly retry the attempt run: TaskRun,
return await marqs?.replaceMessage( retryTimestamp: number,
run.id, checkpointEventId?: string,
{ supportsLazyAttempts?: boolean
type: "EXECUTE", ) {
taskIdentifier: run.taskIdentifier, if (checkpointEventId || !supportsLazyAttempts) {
checkpointEventId: checkpointEventId, // We have to replace a potential RESUME with EXECUTE to correctly retry the attempt
}, return await marqs?.replaceMessage(
retryTimestamp run.id,
); {
type: "EXECUTE",
taskIdentifier: run.taskIdentifier,
checkpointEventId: checkpointEventId,
},
retryTimestamp
);
} else {
// There's no checkpoint so the worker is still running and waiting for a retry message
// It supports lazy attempts so we can bypass the queue and send the message directly to the worker
RetryAttemptService.enqueue(run.id, this._prisma, new Date(retryTimestamp));
}
} }
#generateMetadataAttributesForNextAttempt(execution: TaskRunExecution) { #generateMetadataAttributesForNextAttempt(execution: TaskRunExecution) {
@@ -353,6 +388,7 @@ async function findAttempt(prismaClient: PrismaClientOrTransaction, friendlyId:
include: { include: {
taskRun: true, taskRun: true,
backgroundWorkerTask: true, backgroundWorkerTask: true,
backgroundWorker: true,
}, },
}); });
} }
@@ -63,6 +63,7 @@ export class CreateBackgroundWorkerService extends BaseService {
contentHash: body.metadata.contentHash, contentHash: body.metadata.contentHash,
cliVersion: body.metadata.cliPackageVersion, cliVersion: body.metadata.cliPackageVersion,
sdkVersion: body.metadata.packageVersion, sdkVersion: body.metadata.packageVersion,
supportsLazyAttempts: body.supportsLazyAttempts,
}, },
}); });
@@ -45,6 +45,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
contentHash: body.metadata.contentHash, contentHash: body.metadata.contentHash,
cliVersion: body.metadata.cliPackageVersion, cliVersion: body.metadata.cliPackageVersion,
sdkVersion: body.metadata.packageVersion, sdkVersion: body.metadata.packageVersion,
supportsLazyAttempts: body.supportsLazyAttempts,
}, },
}); });
@@ -0,0 +1,213 @@
import { TaskRunExecution } from "@trigger.dev/core/v3";
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { generateFriendlyId } from "../friendlyIdentifiers";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { TaskRun, TaskRunAttempt } from "@trigger.dev/database";
export class CreateTaskRunAttemptService extends BaseService {
public async call(
runId: string,
env?: AuthenticatedEnvironment,
setToExecuting = true
): Promise<{
execution: TaskRunExecution;
run: TaskRun;
attempt: TaskRunAttempt;
}> {
const environment = env ?? (await getAuthenticatedEnvironmentFromRun(runId, this._prisma));
if (!environment) {
throw new ServiceValidationError("Environment not found", 404);
}
const isFriendlyId = runId.startsWith("run_");
return await this.traceWithEnv("call()", environment, async (span) => {
if (isFriendlyId) {
span.setAttribute("taskRunFriendlyId", runId);
} else {
span.setAttribute("taskRunId", runId);
}
const taskRun = await this._prisma.taskRun.findUnique({
where: {
id: !isFriendlyId ? runId : undefined,
friendlyId: isFriendlyId ? runId : undefined,
runtimeEnvironmentId: environment.id,
},
include: {
tags: true,
attempts: {
take: 1,
orderBy: {
number: "desc",
},
},
lockedBy: {
include: {
worker: true,
},
},
batchItems: {
include: {
batchTaskRun: true,
},
},
},
});
logger.debug("Creating a task run attempt", { taskRun });
if (!taskRun) {
throw new ServiceValidationError("Task run not found", 404);
}
span.setAttribute("taskRunId", taskRun.id);
span.setAttribute("taskRunFriendlyId", taskRun.friendlyId);
if (taskRun.status === "CANCELED") {
throw new ServiceValidationError("Task run is cancelled", 400);
}
if (!taskRun.lockedBy) {
throw new ServiceValidationError("Task run is not locked", 400);
}
const queue = await this._prisma.taskQueue.findUnique({
where: {
runtimeEnvironmentId_name: {
runtimeEnvironmentId: environment.id,
name: taskRun.queue,
},
},
});
if (!queue) {
throw new ServiceValidationError("Queue not found", 404);
}
const nextAttemptNumber = taskRun.attempts[0] ? taskRun.attempts[0].number + 1 : 1;
const taskRunAttempt = await $transaction(this._prisma, async (tx) => {
const taskRunAttempt = await tx.taskRunAttempt.create({
data: {
number: nextAttemptNumber,
friendlyId: generateFriendlyId("attempt"),
taskRunId: taskRun.id,
startedAt: new Date(),
backgroundWorkerId: taskRun.lockedBy!.worker.id,
backgroundWorkerTaskId: taskRun.lockedBy!.id,
status: setToExecuting ? "EXECUTING" : "PENDING",
queueId: queue.id,
runtimeEnvironmentId: environment.id,
},
include: {
backgroundWorker: true,
backgroundWorkerTask: true,
},
});
if (setToExecuting) {
await tx.taskRun.update({
where: {
id: taskRun.id,
},
data: {
status: "EXECUTING",
},
});
}
return taskRunAttempt;
});
if (!taskRunAttempt) {
logger.error("Failed to create task run attempt", { runId: taskRun.id, nextAttemptNumber });
throw new ServiceValidationError("Failed to create task run attempt", 500);
}
const execution: TaskRunExecution = {
task: {
id: taskRun.lockedBy.slug,
filePath: taskRun.lockedBy.filePath,
exportName: taskRun.lockedBy.exportName,
},
attempt: {
id: taskRunAttempt.friendlyId,
number: taskRunAttempt.number,
startedAt: taskRunAttempt.startedAt ?? taskRunAttempt.createdAt,
backgroundWorkerId: taskRun.lockedBy.worker.id,
backgroundWorkerTaskId: taskRun.lockedBy.id,
status: "EXECUTING" as const,
},
run: {
id: taskRun.friendlyId,
payload: taskRun.payload,
payloadType: taskRun.payloadType,
context: taskRun.context,
createdAt: taskRun.createdAt,
tags: taskRun.tags.map((tag) => tag.name),
isTest: taskRun.isTest,
idempotencyKey: taskRun.idempotencyKey ?? undefined,
},
queue: {
id: queue.friendlyId,
name: queue.name,
},
environment: {
id: environment.id,
slug: environment.slug,
type: environment.type,
},
organization: {
id: environment.organization.id,
slug: environment.organization.slug,
name: environment.organization.title,
},
project: {
id: environment.project.id,
ref: environment.project.externalRef,
slug: environment.project.slug,
name: environment.project.name,
},
batch:
taskRun.batchItems[0] && taskRun.batchItems[0].batchTaskRun
? { id: taskRun.batchItems[0].batchTaskRun.friendlyId }
: undefined,
};
return {
execution,
run: taskRun,
attempt: taskRunAttempt,
};
});
}
}
async function getAuthenticatedEnvironmentFromRun(
friendlyId: string,
prismaClient?: PrismaClientOrTransaction
) {
const taskRun = await (prismaClient ?? prisma).taskRun.findUnique({
where: {
friendlyId,
},
include: {
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
},
});
if (!taskRun) {
return;
}
return taskRun?.runtimeEnvironment;
}
@@ -0,0 +1,39 @@
import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { socketIo } from "../handleSocketIo.server";
import { PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
export class RetryAttemptService extends BaseService {
public async call(runId: string) {
const taskRun = await this._prisma.taskRun.findFirst({
where: {
id: runId,
},
});
if (!taskRun) {
logger.error("Task run not found", { runId });
return;
}
socketIo.coordinatorNamespace.emit("READY_FOR_RETRY", {
version: "v1",
runId,
});
}
static async enqueue(runId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"v3.retryAttempt",
{
runId,
},
{
tx,
runAt,
jobKey: `retryAttempt:${runId}`,
}
);
}
}
@@ -4,13 +4,12 @@ import {
TriggerTaskRequestBody, TriggerTaskRequestBody,
packetRequiresOffloading, packetRequiresOffloading,
} from "@trigger.dev/core/v3"; } from "@trigger.dev/core/v3";
import { nanoid } from "nanoid";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { $transaction } from "~/db.server"; import { $transaction } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { eventRepository } from "../eventRepository.server"; import { eventRepository } from "../eventRepository.server";
import { generateFriendlyId } from "../friendlyIdentifiers"; import { generateFriendlyId } from "../friendlyIdentifiers";
import { marqs } from "~/v3/marqs/index.server"; import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server";
import { uploadToObjectStore } from "../r2.server"; import { uploadToObjectStore } from "../r2.server";
import { BaseService } from "./baseService.server"; import { BaseService } from "./baseService.server";
@@ -112,7 +111,7 @@ export class TriggerTaskService extends BaseService {
select: { lastNumber: true }, select: { lastNumber: true },
}); });
const queueName = body.options?.queue?.name ?? `task/${taskId}`; const queueName = sanitizeQueueName(body.options?.queue?.name ?? `task/${taskId}`);
event.setAttribute("queueName", queueName); event.setAttribute("queueName", queueName);
span.setAttribute("queueName", queueName); span.setAttribute("queueName", queueName);
+69 -62
View File
@@ -16,7 +16,10 @@ import {
GetProjectResponseBody, GetProjectResponseBody,
ImportEnvironmentVariablesRequestBody, ImportEnvironmentVariablesRequestBody,
EnvironmentVariableResponseBody, EnvironmentVariableResponseBody,
TaskRunExecution,
APIError,
} from "@trigger.dev/core/v3"; } from "@trigger.dev/core/v3";
import { zodfetch } from "@trigger.dev/core/v3/zodfetch";
export class CliApiClient { export class CliApiClient {
private readonly apiURL: string; private readonly apiURL: string;
@@ -29,7 +32,7 @@ export class CliApiClient {
} }
async createAuthorizationCode() { async createAuthorizationCode() {
return zodfetch( return wrapZodFetch(
CreateAuthorizationCodeResponseSchema, CreateAuthorizationCodeResponseSchema,
`${this.apiURL}/api/v1/authorization-code`, `${this.apiURL}/api/v1/authorization-code`,
{ {
@@ -39,7 +42,7 @@ export class CliApiClient {
} }
async getPersonalAccessToken(authorizationCode: string) { async getPersonalAccessToken(authorizationCode: string) {
return zodfetch(GetPersonalAccessTokenResponseSchema, `${this.apiURL}/api/v1/token`, { return wrapZodFetch(GetPersonalAccessTokenResponseSchema, `${this.apiURL}/api/v1/token`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
authorizationCode, authorizationCode,
@@ -52,7 +55,7 @@ export class CliApiClient {
throw new Error("whoAmI: No access token"); throw new Error("whoAmI: No access token");
} }
return zodfetch(WhoAmIResponseSchema, `${this.apiURL}/api/v2/whoami`, { return wrapZodFetch(WhoAmIResponseSchema, `${this.apiURL}/api/v2/whoami`, {
headers: { headers: {
Authorization: `Bearer ${this.accessToken}`, Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -65,7 +68,7 @@ export class CliApiClient {
throw new Error("getProject: No access token"); throw new Error("getProject: No access token");
} }
return zodfetch(GetProjectResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}`, { return wrapZodFetch(GetProjectResponseBody, `${this.apiURL}/api/v1/projects/${projectRef}`, {
headers: { headers: {
Authorization: `Bearer ${this.accessToken}`, Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -78,7 +81,7 @@ export class CliApiClient {
throw new Error("getProjects: No access token"); throw new Error("getProjects: No access token");
} }
return zodfetch(GetProjectsResponseBody, `${this.apiURL}/api/v1/projects`, { return wrapZodFetch(GetProjectsResponseBody, `${this.apiURL}/api/v1/projects`, {
headers: { headers: {
Authorization: `Bearer ${this.accessToken}`, Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json", "Content-Type": "application/json",
@@ -91,7 +94,7 @@ export class CliApiClient {
throw new Error("createBackgroundWorker: No access token"); throw new Error("createBackgroundWorker: No access token");
} }
return zodfetch( return wrapZodFetch(
CreateBackgroundWorkerResponse, CreateBackgroundWorkerResponse,
`${this.apiURL}/api/v1/projects/${projectRef}/background-workers`, `${this.apiURL}/api/v1/projects/${projectRef}/background-workers`,
{ {
@@ -105,6 +108,20 @@ export class CliApiClient {
); );
} }
async createTaskRunAttempt(runFriendlyId: string) {
if (!this.accessToken) {
throw new Error("creatTaskRunAttempt: No access token");
}
return wrapZodFetch(TaskRunExecution, `${this.apiURL}/api/v1/runs/${runFriendlyId}/attempts`, {
method: "POST",
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
});
}
async getProjectEnv({ async getProjectEnv({
projectRef, projectRef,
env, env,
@@ -116,12 +133,16 @@ export class CliApiClient {
throw new Error("getProjectDevEnv: No access token"); throw new Error("getProjectDevEnv: No access token");
} }
return zodfetch(GetProjectEnvResponse, `${this.apiURL}/api/v1/projects/${projectRef}/${env}`, { return wrapZodFetch(
headers: { GetProjectEnvResponse,
Authorization: `Bearer ${this.accessToken}`, `${this.apiURL}/api/v1/projects/${projectRef}/${env}`,
"Content-Type": "application/json", {
}, headers: {
}); Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
},
}
);
} }
async getEnvironmentVariables(projectRef: string) { async getEnvironmentVariables(projectRef: string) {
@@ -129,7 +150,7 @@ export class CliApiClient {
throw new Error("getEnvironmentVariables: No access token"); throw new Error("getEnvironmentVariables: No access token");
} }
return zodfetch( return wrapZodFetch(
GetEnvironmentVariablesResponseBody, GetEnvironmentVariablesResponseBody,
`${this.apiURL}/api/v1/projects/${projectRef}/envvars`, `${this.apiURL}/api/v1/projects/${projectRef}/envvars`,
{ {
@@ -150,7 +171,7 @@ export class CliApiClient {
throw new Error("importEnvVars: No access token"); throw new Error("importEnvVars: No access token");
} }
return zodfetch( return wrapZodFetch(
EnvironmentVariableResponseBody, EnvironmentVariableResponseBody,
`${this.apiURL}/api/v1/projects/${projectRef}/envvars/${slug}/import`, `${this.apiURL}/api/v1/projects/${projectRef}/envvars/${slug}/import`,
{ {
@@ -169,7 +190,7 @@ export class CliApiClient {
throw new Error("initializeDeployment: No access token"); throw new Error("initializeDeployment: No access token");
} }
return zodfetch(InitializeDeploymentResponseBody, `${this.apiURL}/api/v1/deployments`, { return wrapZodFetch(InitializeDeploymentResponseBody, `${this.apiURL}/api/v1/deployments`, {
method: "POST", method: "POST",
headers: { headers: {
Authorization: `Bearer ${this.accessToken}`, Authorization: `Bearer ${this.accessToken}`,
@@ -184,7 +205,7 @@ export class CliApiClient {
throw new Error("startDeploymentIndexing: No access token"); throw new Error("startDeploymentIndexing: No access token");
} }
return zodfetch( return wrapZodFetch(
StartDeploymentIndexingResponseBody, StartDeploymentIndexingResponseBody,
`${this.apiURL}/api/v1/deployments/${deploymentId}/start-indexing`, `${this.apiURL}/api/v1/deployments/${deploymentId}/start-indexing`,
{ {
@@ -203,7 +224,7 @@ export class CliApiClient {
throw new Error("getDeployment: No access token"); throw new Error("getDeployment: No access token");
} }
return zodfetch( return wrapZodFetch(
GetDeploymentResponseBody, GetDeploymentResponseBody,
`${this.apiURL}/api/v1/deployments/${deploymentId}`, `${this.apiURL}/api/v1/deployments/${deploymentId}`,
{ {
@@ -223,56 +244,42 @@ type ApiResult<TSuccessResult> =
error: string; error: string;
}; };
async function zodfetch<TResponseBody extends any>( async function wrapZodFetch<T extends z.ZodTypeAny>(
schema: z.Schema<TResponseBody>, schema: T,
url: string, url: string,
requestInit?: RequestInit requestInit?: RequestInit
): Promise<ApiResult<TResponseBody>> { ): Promise<ApiResult<z.infer<T>>> {
try { try {
const response = await fetch(url, requestInit); const response = await zodfetch(schema, url, requestInit, {
retry: {
minTimeoutInMs: 500,
maxTimeoutInMs: 5000,
maxAttempts: 3,
factor: 2,
randomize: false,
},
});
if ((!requestInit || requestInit.method === "GET") && response.status === 404) {
return {
success: false,
error: `404: ${response.statusText}`,
};
}
if (response.status >= 400 && response.status < 500) {
const body = await response.json();
if (!body.error) {
return { success: false, error: "Something went wrong" };
}
return { success: false, error: body.error };
}
if (response.status !== 200) {
return {
success: false,
error: `Failed to fetch ${url}, got status code ${response.status}`,
};
}
const jsonBody = await response.json();
const parsedResult = schema.safeParse(jsonBody);
if (parsedResult.success) {
return { success: true, data: parsedResult.data };
}
if ("error" in jsonBody) {
return {
success: false,
error: typeof jsonBody.error === "string" ? jsonBody.error : JSON.stringify(jsonBody.error),
};
}
return { success: false, error: parsedResult.error.message };
} catch (error) {
return { return {
success: false, success: true,
error: error instanceof Error ? error.message : JSON.stringify(error), data: response,
}; };
} catch (error) {
if (error instanceof APIError) {
return {
success: false,
error: error.message,
};
} else if (error instanceof Error) {
return {
success: false,
error: error.message,
};
} else {
return {
success: false,
error: String(error),
};
}
} }
} }
+3 -1
View File
@@ -199,7 +199,9 @@ async function _deployCommand(dir: string, options: DeployCommandOptions) {
`Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?` `Failed to connect to ${authorization.auth?.apiUrl}. Are you sure it's the correct URL?`
); );
} else { } else {
throw new Error("You must login first. Use `trigger.dev login` to login."); throw new Error(
`You must login first. Use the \`login\` CLI command.\n\n${authorization.error}`
);
} }
} }
+52 -25
View File
@@ -112,7 +112,11 @@ export async function devCommand(dir: string, options: DevCommandOptions) {
)} Connecting to the server failed. Please check your internet connection or contact eric@trigger.dev for help.` )} Connecting to the server failed. Please check your internet connection or contact eric@trigger.dev for help.`
); );
} else { } else {
logger.log(`${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.`); logger.log(
`${chalkError("X Error:")} You must login first. Use the \`login\` CLI command.\n\n${
authorization.error
}`
);
} }
process.exitCode = 1; process.exitCode = 1;
return; return;
@@ -285,6 +289,7 @@ function useDev({
websocket.addEventListener("close", (event) => {}); websocket.addEventListener("close", (event) => {});
websocket.addEventListener("error", (event) => {}); websocket.addEventListener("error", (event) => {});
// This is the deprecated task heart beat that uses the friendly attempt ID
backgroundWorkerCoordinator.onWorkerTaskHeartbeat.attach( backgroundWorkerCoordinator.onWorkerTaskHeartbeat.attach(
async ({ worker, backgroundWorkerId, id }) => { async ({ worker, backgroundWorkerId, id }) => {
await sender.send("BACKGROUND_WORKER_MESSAGE", { await sender.send("BACKGROUND_WORKER_MESSAGE", {
@@ -297,6 +302,19 @@ function useDev({
} }
); );
// "Task Run Heartbeat" id is the actual run ID that corresponds to the MarQS message ID
backgroundWorkerCoordinator.onWorkerTaskRunHeartbeat.attach(
async ({ worker, backgroundWorkerId, id }) => {
await sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId,
data: {
type: "TASK_RUN_HEARTBEAT",
id,
},
});
}
);
backgroundWorkerCoordinator.onTaskCompleted.attach( backgroundWorkerCoordinator.onTaskCompleted.attach(
async ({ backgroundWorkerId, completion, execution }) => { async ({ backgroundWorkerId, completion, execution }) => {
await sender.send("BACKGROUND_WORKER_MESSAGE", { await sender.send("BACKGROUND_WORKER_MESSAGE", {
@@ -310,6 +328,18 @@ function useDev({
} }
); );
backgroundWorkerCoordinator.onTaskFailedToRun.attach(
async ({ backgroundWorkerId, completion }) => {
await sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId,
data: {
type: "TASK_RUN_FAILED_TO_RUN",
completion,
},
});
}
);
backgroundWorkerCoordinator.onWorkerRegistered.attach(async ({ id, worker, record }) => { backgroundWorkerCoordinator.onWorkerRegistered.attach(async ({ id, worker, record }) => {
await sender.send("READY_FOR_TASKS", { await sender.send("READY_FOR_TASKS", {
backgroundWorkerId: id, backgroundWorkerId: id,
@@ -334,6 +364,7 @@ function useDev({
for (const worker of backgroundWorkerCoordinator.currentWorkers) { for (const worker of backgroundWorkerCoordinator.currentWorkers) {
await sender.send("READY_FOR_TASKS", { await sender.send("READY_FOR_TASKS", {
backgroundWorkerId: worker.id, backgroundWorkerId: worker.id,
inProgressRuns: worker.worker.inProgressRuns,
}); });
} }
}, },
@@ -505,21 +536,25 @@ function useDev({
const processEnv = await gatherProcessEnv(); const processEnv = await gatherProcessEnv();
const backgroundWorker = new BackgroundWorker(fullPath, { const backgroundWorker = new BackgroundWorker(
projectConfig: config, fullPath,
dependencies, {
env: { projectConfig: config,
...processEnv, dependencies,
TRIGGER_API_URL: apiUrl, env: {
TRIGGER_SECRET_KEY: apiKey, ...processEnv,
...(environmentVariablesResponse.success TRIGGER_API_URL: apiUrl,
? environmentVariablesResponse.data.variables TRIGGER_SECRET_KEY: apiKey,
: {}), ...(environmentVariablesResponse.success
? environmentVariablesResponse.data.variables
: {}),
},
debuggerOn,
debugOtel,
resolveEnvVariables: createResolveEnvironmentVariablesFunction(configModule),
}, },
debuggerOn, environmentClient
debugOtel, );
resolveEnvVariables: createResolveEnvironmentVariablesFunction(configModule),
});
try { try {
await backgroundWorker.initialize(); await backgroundWorker.initialize();
@@ -576,6 +611,7 @@ function useDev({
tasks: taskResources, tasks: taskResources,
contentHash: contentHash, contentHash: contentHash,
}, },
supportsLazyAttempts: true,
}; };
const backgroundWorkerRecord = await environmentClient.createBackgroundWorker( const backgroundWorkerRecord = await environmentClient.createBackgroundWorker(
@@ -827,18 +863,9 @@ function createDuplicateTaskIdOutputErrorMessage(
async function gatherProcessEnv() { async function gatherProcessEnv() {
const env = { const env = {
...process.env,
NODE_ENV: process.env.NODE_ENV ?? "development", NODE_ENV: process.env.NODE_ENV ?? "development",
PATH: process.env.PATH,
USER: process.env.USER,
SHELL: process.env.SHELL,
NVM_INC: process.env.NVM_INC,
NVM_DIR: process.env.NVM_DIR,
NVM_BIN: process.env.NVM_BIN,
LANG: process.env.LANG,
TERM: process.env.TERM,
NODE_PATH: await amendNodePathWithPnpmNodeModules(process.env.NODE_PATH), NODE_PATH: await amendNodePathWithPnpmNodeModules(process.env.NODE_PATH),
HOME: process.env.HOME,
BUN_INSTALL: process.env.BUN_INSTALL,
}; };
// Filter out undefined values // Filter out undefined values
+1 -1
View File
@@ -78,7 +78,7 @@ export async function whoAmI(
options?.profile ?? "default" options?.profile ?? "default"
}\` to login.` }\` to login.`
); );
outro("Whoami failed"); outro(`Whoami failed: ${authentication.error}`);
} }
} }
@@ -21,3 +21,43 @@ export class TaskMetadataParseError extends Error {
this.name = "TaskMetadataParseError"; this.name = "TaskMetadataParseError";
} }
} }
export class UnexpectedExitError extends Error {
constructor(public code: number) {
super(`Unexpected exit with code ${code}`);
this.name = "UnexpectedExitError";
}
}
export class CleanupProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CleanupProcessError";
}
}
export class CancelledProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CancelledProcessError";
}
}
export class SigKillTimeoutProcessError extends Error {
constructor() {
super("Process kill timeout");
this.name = "SigKillTimeoutProcessError";
}
}
export class GracefulExitTimeoutError extends Error {
constructor() {
super("Graceful exit timeout");
this.name = "GracefulExitTimeoutError";
}
}
@@ -9,8 +9,10 @@ import {
TaskRunError, TaskRunError,
TaskRunErrorCodes, TaskRunErrorCodes,
TaskRunExecution, TaskRunExecution,
TaskRunExecutionLazyAttemptPayload,
TaskRunExecutionPayload, TaskRunExecutionPayload,
TaskRunExecutionResult, TaskRunExecutionResult,
TaskRunFailedExecutionResult,
childToWorkerMessages, childToWorkerMessages,
correctErrorStackTrace, correctErrorStackTrace,
formatDurationMilliseconds, formatDurationMilliseconds,
@@ -36,8 +38,15 @@ import {
import { safeDeleteFileSync } from "../../utilities/fileSystem.js"; import { safeDeleteFileSync } from "../../utilities/fileSystem.js";
import { installPackages } from "../../utilities/installPackages.js"; import { installPackages } from "../../utilities/installPackages.js";
import { logger } from "../../utilities/logger.js"; import { logger } from "../../utilities/logger.js";
import { TaskMetadataParseError, UncaughtExceptionError } from "../common/errors.js"; import {
import { env } from "node:process"; CancelledProcessError,
CleanupProcessError,
SigKillTimeoutProcessError,
TaskMetadataParseError,
UncaughtExceptionError,
UnexpectedExitError,
} from "../common/errors.js";
import { CliApiClient } from "../../apiClient.js";
export type CurrentWorkers = BackgroundWorkerCoordinator["currentWorkers"]; export type CurrentWorkers = BackgroundWorkerCoordinator["currentWorkers"];
export class BackgroundWorkerCoordinator { export class BackgroundWorkerCoordinator {
@@ -47,37 +56,52 @@ export class BackgroundWorkerCoordinator {
worker: BackgroundWorker; worker: BackgroundWorker;
execution: TaskRunExecution; execution: TaskRunExecution;
}> = new Evt(); }> = new Evt();
public onTaskFailedToRun: Evt<{
backgroundWorkerId: string;
worker: BackgroundWorker;
completion: TaskRunFailedExecutionResult;
}> = new Evt();
public onWorkerRegistered: Evt<{ public onWorkerRegistered: Evt<{
worker: BackgroundWorker; worker: BackgroundWorker;
id: string; id: string;
record: CreateBackgroundWorkerResponse; record: CreateBackgroundWorkerResponse;
}> = new Evt(); }> = new Evt();
/**
* @deprecated use onWorkerTaskRunHeartbeat instead
*/
public onWorkerTaskHeartbeat: Evt<{ public onWorkerTaskHeartbeat: Evt<{
id: string; id: string;
backgroundWorkerId: string; backgroundWorkerId: string;
worker: BackgroundWorker; worker: BackgroundWorker;
}> = new Evt(); }> = new Evt();
public onWorkerTaskRunHeartbeat: Evt<{
id: string;
backgroundWorkerId: string;
worker: BackgroundWorker;
}> = new Evt();
public onWorkerDeprecated: Evt<{ worker: BackgroundWorker; id: string }> = new Evt(); public onWorkerDeprecated: Evt<{ worker: BackgroundWorker; id: string }> = new Evt();
private _backgroundWorkers: Map<string, BackgroundWorker> = new Map(); private _backgroundWorkers: Map<string, BackgroundWorker> = new Map();
private _records: Map<string, CreateBackgroundWorkerResponse> = new Map(); private _records: Map<string, CreateBackgroundWorkerResponse> = new Map();
private _deprecatedWorkers: Set<string> = new Set(); private _deprecatedWorkers: Set<string> = new Set();
constructor(private baseURL: string) { constructor(private baseURL: string) {
this.onTaskCompleted.attach(async ({ completion, execution }) => { this.onTaskCompleted.attach(async ({ completion }) => {
if (!completion.ok && typeof completion.retry !== "undefined") { if (!completion.ok && typeof completion.retry !== "undefined") {
return; return;
} }
await this.#notifyWorkersOfTaskCompletion(completion, execution); await this.#notifyWorkersOfTaskCompletion(completion);
});
this.onTaskFailedToRun.attach(async ({ completion }) => {
await this.#notifyWorkersOfTaskCompletion(completion);
}); });
} }
async #notifyWorkersOfTaskCompletion( async #notifyWorkersOfTaskCompletion(completion: TaskRunExecutionResult) {
completion: TaskRunExecutionResult,
execution: TaskRunExecution
) {
for (const worker of this._backgroundWorkers.values()) { for (const worker of this._backgroundWorkers.values()) {
await worker.taskRunCompletedNotification(completion, execution); await worker.taskRunCompletedNotification(completion);
} }
} }
@@ -107,6 +131,10 @@ export class BackgroundWorkerCoordinator {
worker.onTaskHeartbeat.attach((id) => { worker.onTaskHeartbeat.attach((id) => {
this.onWorkerTaskHeartbeat.post({ id, backgroundWorkerId: record.id, worker }); this.onWorkerTaskHeartbeat.post({ id, backgroundWorkerId: record.id, worker });
}); });
worker.onTaskRunHeartbeat.attach((id) => {
this.onWorkerTaskRunHeartbeat.post({ id, backgroundWorkerId: record.id, worker });
});
} }
close() { close() {
@@ -136,7 +164,65 @@ export class BackgroundWorkerCoordinator {
} }
await worker.cancelRun(message.taskRunId); await worker.cancelRun(message.taskRunId);
break;
} }
case "EXECUTE_RUN_LAZY_ATTEMPT": {
await this.#executeTaskRunLazyAttempt(id, message.payload);
}
}
}
async #executeTaskRunLazyAttempt(id: string, payload: TaskRunExecutionLazyAttemptPayload) {
const worker = this._backgroundWorkers.get(id);
if (!worker) {
logger.error(`Could not find worker ${id}`);
return;
}
const record = this._records.get(id);
if (!record) {
logger.error(`Could not find worker record ${id}`);
return;
}
try {
const { completion, execution } = await worker.executeTaskRunLazyAttempt(
payload,
this.baseURL
);
this.onTaskCompleted.post({
completion,
execution,
worker,
backgroundWorkerId: id,
});
} catch (error) {
this.onTaskFailedToRun.post({
backgroundWorkerId: id,
worker,
completion: {
ok: false,
id: payload.runId,
retry: undefined,
error:
error instanceof Error
? {
type: "BUILT_IN_ERROR",
name: error.name,
message: error.message,
stackTrace: error.stack ?? "",
}
: {
type: "BUILT_IN_ERROR",
name: "UnknownError",
message: String(error),
stackTrace: "",
},
},
});
} }
} }
@@ -155,106 +241,14 @@ export class BackgroundWorkerCoordinator {
return; return;
} }
const { execution } = payload; const completion = await worker.executeTaskRun(payload, this.baseURL);
// ○ Mar 27 09:17:25.653 -> View logs | 20240326.20 | create-avatar | run_slufhjdfiv8ejnrkw9dsj.1 this.onTaskCompleted.post({
completion,
const logsUrl = `${this.baseURL}/runs/${execution.run.id}`; execution: payload.execution,
worker,
const pipe = chalkGrey("|"); backgroundWorkerId: id,
const bullet = chalkGrey("○"); });
const link = chalkLink(terminalLink("View logs", logsUrl));
let timestampPrefix = chalkGrey(prettyPrintDate(payload.execution.attempt.startedAt));
const workerPrefix = chalkWorker(record.version);
const taskPrefix = chalkTask(execution.task.id);
const runId = chalkRun(`${execution.run.id}.${execution.attempt.number}`);
logger.log(
`${bullet} ${timestampPrefix} ${chalkGrey(
"->"
)} ${link} ${pipe} ${workerPrefix} ${pipe} ${taskPrefix} ${pipe} ${runId}`
);
const now = performance.now();
const completion = await worker.executeTaskRun(payload);
const elapsed = performance.now() - now;
const retryingText = chalkGrey(
!completion.ok && completion.skippedRetrying
? " (retrying skipped)"
: !completion.ok && completion.retry !== undefined
? ` (retrying in ${completion.retry.delay}ms)`
: ""
);
const resultText = !completion.ok
? completion.error.type === "INTERNAL_ERROR" &&
(completion.error.code === TaskRunErrorCodes.TASK_EXECUTION_ABORTED ||
completion.error.code === TaskRunErrorCodes.TASK_RUN_CANCELLED)
? chalkWarning("Cancelled")
: `${chalkError("Error")}${retryingText}`
: chalkSuccess("Success");
const errorText = !completion.ok
? this.#formatErrorLog(completion.error)
: "retry" in completion
? `retry in ${completion.retry}ms`
: "";
const elapsedText = chalkGrey(`(${formatDurationMilliseconds(elapsed, { style: "short" })})`);
timestampPrefix = chalkGrey(prettyPrintDate());
logger.log(
`${bullet} ${timestampPrefix} ${chalkGrey(
"->"
)} ${link} ${pipe} ${workerPrefix} ${pipe} ${taskPrefix} ${pipe} ${runId} ${pipe} ${resultText} ${elapsedText}${errorText}`
);
this.onTaskCompleted.post({ completion, execution, worker, backgroundWorkerId: id });
}
#formatErrorLog(error: TaskRunError) {
switch (error.type) {
case "INTERNAL_ERROR": {
return "";
}
case "STRING_ERROR": {
return `\n\n${chalkError("X Error:")} ${error.raw}\n`;
}
case "CUSTOM_ERROR": {
return `\n\n${chalkError("X Error:")} ${error.raw}\n`;
}
case "BUILT_IN_ERROR": {
return `\n\n${error.stackTrace.replace(/^Error: /, chalkError("X Error: "))}\n`;
}
}
}
}
class UnexpectedExitError extends Error {
constructor(public code: number) {
super(`Unexpected exit with code ${code}`);
this.name = "UnexpectedExitError";
}
}
class CleanupProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CleanupProcessError";
}
}
class CancelledProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CancelledProcessError";
} }
} }
@@ -276,13 +270,18 @@ export class BackgroundWorker {
schema: childToWorkerMessages, schema: childToWorkerMessages,
}); });
/**
* @deprecated use onTaskRunHeartbeat instead
*/
public onTaskHeartbeat: Evt<string> = new Evt(); public onTaskHeartbeat: Evt<string> = new Evt();
public onTaskRunHeartbeat: Evt<string> = new Evt();
private _onClose: Evt<void> = new Evt(); private _onClose: Evt<void> = new Evt();
public tasks: Array<TaskMetadataWithFilePath> = []; public tasks: Array<TaskMetadataWithFilePath> = [];
public metadata: BackgroundWorkerProperties | undefined; public metadata: BackgroundWorkerProperties | undefined;
_taskRunProcesses: Map<string, TaskRunProcess> = new Map(); _taskRunProcesses: Map<string, TaskRunProcess> = new Map();
private _taskRunProcessesBeingKilled: Set<number> = new Set();
private _closed: boolean = false; private _closed: boolean = false;
@@ -290,7 +289,8 @@ export class BackgroundWorker {
constructor( constructor(
public path: string, public path: string,
public params: BackgroundWorkerParams public params: BackgroundWorkerParams,
private apiClient: CliApiClient
) {} ) {}
close() { close() {
@@ -301,6 +301,7 @@ export class BackgroundWorker {
this._closed = true; this._closed = true;
this.onTaskHeartbeat.detach(); this.onTaskHeartbeat.detach();
this.onTaskRunHeartbeat.detach();
// We need to close all the task run processes // We need to close all the task run processes
for (const taskRunProcess of this._taskRunProcesses.values()) { for (const taskRunProcess of this._taskRunProcesses.values()) {
@@ -314,6 +315,10 @@ export class BackgroundWorker {
safeDeleteFileSync(`${this.path}.map`); safeDeleteFileSync(`${this.path}.map`);
} }
get inProgressRuns(): Array<string> {
return Array.from(this._taskRunProcesses.keys());
}
async initialize() { async initialize() {
if (this._initialized) { if (this._initialized) {
throw new Error("Worker already initialized"); throw new Error("Worker already initialized");
@@ -408,46 +413,142 @@ export class BackgroundWorker {
// We need to notify all the task run processes that a task run has completed, // We need to notify all the task run processes that a task run has completed,
// in case they are waiting for it through triggerAndWait // in case they are waiting for it through triggerAndWait
async taskRunCompletedNotification( async taskRunCompletedNotification(completion: TaskRunExecutionResult) {
completion: TaskRunExecutionResult,
execution: TaskRunExecution
) {
for (const taskRunProcess of this._taskRunProcesses.values()) { for (const taskRunProcess of this._taskRunProcesses.values()) {
taskRunProcess.taskRunCompletedNotification(completion, execution); taskRunProcess.taskRunCompletedNotification(completion);
} }
} }
async #initializeTaskRunProcess(payload: TaskRunExecutionPayload): Promise<TaskRunProcess> { #prefixedMessage(payload: TaskRunExecutionPayload, message: string = "") {
return `[${payload.execution.run.id}.${payload.execution.attempt.number}] ${message}`;
}
async #getFreshTaskRunProcess(
payload: TaskRunExecutionPayload,
messageId?: string
): Promise<TaskRunProcess> {
logger.debug(this.#prefixedMessage(payload, "getFreshTaskRunProcess()"));
if (!this.metadata) { if (!this.metadata) {
throw new Error("Worker not registered"); throw new Error("Worker not registered");
} }
if (!this._taskRunProcesses.has(payload.execution.run.id)) { this._closed = false;
const taskRunProcess = new TaskRunProcess(
payload.execution,
this.path,
{
...this._fullEnv,
...(payload.environment ?? {}),
},
this.metadata,
this.params
);
taskRunProcess.onExit.attach(() => { logger.debug(this.#prefixedMessage(payload, "killing current task run process before attempt"));
await this.#killCurrentTaskRunProcessBeforeAttempt(payload.execution.run.id);
logger.debug(this.#prefixedMessage(payload, "creating new task run process"));
const taskRunProcess = new TaskRunProcess(
payload.execution.run.id,
payload.execution.run.isTest,
this.path,
{
...this._fullEnv,
...(payload.environment ?? {}),
...this.#readEnvVars(),
},
this.metadata,
this.params,
messageId
);
taskRunProcess.onExit.attach(({ pid }) => {
logger.debug(this.#prefixedMessage(payload, "onExit()"), { pid });
const taskRunProcess = this._taskRunProcesses.get(payload.execution.run.id);
// Only delete the task run process if the pid matches
if (taskRunProcess?.pid === pid) {
this._taskRunProcesses.delete(payload.execution.run.id); this._taskRunProcesses.delete(payload.execution.run.id);
}); }
taskRunProcess.onTaskHeartbeat.attach((id) => { if (pid) {
this.onTaskHeartbeat.post(id); this._taskRunProcessesBeingKilled.delete(pid);
}); }
});
await taskRunProcess.initialize(); taskRunProcess.onIsBeingKilled.attach((pid) => {
if (pid) {
this._taskRunProcessesBeingKilled.add(pid);
}
});
this._taskRunProcesses.set(payload.execution.run.id, taskRunProcess); taskRunProcess.onTaskHeartbeat.attach((id) => {
this.onTaskHeartbeat.post(id);
});
taskRunProcess.onTaskRunHeartbeat.attach((id) => {
this.onTaskRunHeartbeat.post(id);
});
await taskRunProcess.initialize();
this._taskRunProcesses.set(payload.execution.run.id, taskRunProcess);
return taskRunProcess;
}
async #killCurrentTaskRunProcessBeforeAttempt(runId: string) {
const taskRunProcess = this._taskRunProcesses.get(runId);
if (!taskRunProcess) {
logger.debug(`[${runId}] no current task process to kill`);
return;
} }
return this._taskRunProcesses.get(payload.execution.run.id) as TaskRunProcess; logger.debug(`[${runId}] killing current task process`, {
pid: taskRunProcess.pid,
});
if (taskRunProcess.isBeingKilled) {
if (this._taskRunProcessesBeingKilled.size > 1) {
await this.#tryGracefulExit(taskRunProcess);
} else {
// If there's only one or none being killed, don't do anything so we can create a fresh one in parallel
}
} else {
// It's not being killed, so kill it
if (this._taskRunProcessesBeingKilled.size > 0) {
await this.#tryGracefulExit(taskRunProcess);
} else {
// There's none being killed yet, so we can kill it without waiting. We still set a timeout to kill it forcefully just in case it sticks around.
taskRunProcess.kill("SIGTERM", 5_000).catch(() => {});
}
}
}
async #tryGracefulExit(
taskRunProcess: TaskRunProcess,
kill = false,
initialSignal: number | NodeJS.Signals = "SIGTERM"
) {
try {
const initialExit = taskRunProcess.onExit.waitFor(5_000);
if (kill) {
taskRunProcess.kill(initialSignal);
}
await initialExit;
} catch (error) {
logger.error("TaskRunProcess graceful kill timeout exceeded", error);
this.#tryForcefulExit(taskRunProcess);
}
}
async #tryForcefulExit(taskRunProcess: TaskRunProcess) {
try {
const forcedKill = taskRunProcess.onExit.waitFor(5_000);
taskRunProcess.kill("SIGKILL");
await forcedKill;
} catch (error) {
logger.error("TaskRunProcess forced kill timeout exceeded", error);
throw new SigKillTimeoutProcessError();
}
} }
async cancelRun(taskRunId: string) { async cancelRun(taskRunId: string) {
@@ -460,14 +561,113 @@ export class BackgroundWorker {
await taskRunProcess.cancel(); await taskRunProcess.cancel();
} }
async executeTaskRunLazyAttempt(payload: TaskRunExecutionLazyAttemptPayload, baseURL: string) {
const attemptResponse = await this.apiClient.createTaskRunAttempt(payload.runId);
if (!attemptResponse.success) {
throw new Error(`Failed to create task run attempt: ${attemptResponse.error}`);
}
const execution = attemptResponse.data;
const completion = await this.executeTaskRun(
{ execution, traceContext: payload.traceContext, environment: payload.environment },
baseURL,
payload.messageId
);
return { execution, completion };
}
// We need to fork the process before we can execute any tasks // We need to fork the process before we can execute any tasks
async executeTaskRun(payload: TaskRunExecutionPayload): Promise<TaskRunExecutionResult> { async executeTaskRun(
payload: TaskRunExecutionPayload,
baseURL: string,
messageId?: string
): Promise<TaskRunExecutionResult> {
if (this._closed) {
throw new Error("Worker is closed");
}
if (!this.metadata) {
throw new Error("Worker not registered");
}
const { execution } = payload;
// ○ Mar 27 09:17:25.653 -> View logs | 20240326.20 | create-avatar | run_slufhjdfiv8ejnrkw9dsj.1
const logsUrl = `${baseURL}/runs/${execution.run.id}`;
const pipe = chalkGrey("|");
const bullet = chalkGrey("○");
const link = chalkLink(terminalLink("View logs", logsUrl));
let timestampPrefix = chalkGrey(prettyPrintDate(payload.execution.attempt.startedAt));
const workerPrefix = chalkWorker(this.metadata.version);
const taskPrefix = chalkTask(execution.task.id);
const runId = chalkRun(`${execution.run.id}.${execution.attempt.number}`);
logger.log(
`${bullet} ${timestampPrefix} ${chalkGrey(
"->"
)} ${link} ${pipe} ${workerPrefix} ${pipe} ${taskPrefix} ${pipe} ${runId}`
);
const now = performance.now();
const completion = await this.#doExecuteTaskRun(payload, messageId);
const elapsed = performance.now() - now;
const retryingText = chalkGrey(
!completion.ok && completion.skippedRetrying
? " (retrying skipped)"
: !completion.ok && completion.retry !== undefined
? ` (retrying in ${completion.retry.delay}ms)`
: ""
);
const resultText = !completion.ok
? completion.error.type === "INTERNAL_ERROR" &&
(completion.error.code === TaskRunErrorCodes.TASK_EXECUTION_ABORTED ||
completion.error.code === TaskRunErrorCodes.TASK_RUN_CANCELLED)
? chalkWarning("Cancelled")
: `${chalkError("Error")}${retryingText}`
: chalkSuccess("Success");
const errorText = !completion.ok
? formatErrorLog(completion.error)
: "retry" in completion
? `retry in ${completion.retry}ms`
: "";
const elapsedText = chalkGrey(`(${formatDurationMilliseconds(elapsed, { style: "short" })})`);
timestampPrefix = chalkGrey(prettyPrintDate());
logger.log(
`${bullet} ${timestampPrefix} ${chalkGrey(
"->"
)} ${link} ${pipe} ${workerPrefix} ${pipe} ${taskPrefix} ${pipe} ${runId} ${pipe} ${resultText} ${elapsedText}${errorText}`
);
return completion;
}
async #doExecuteTaskRun(
payload: TaskRunExecutionPayload,
messageId?: string
): Promise<TaskRunExecutionResult> {
try { try {
const taskRunProcess = await this.#initializeTaskRunProcess(payload); const taskRunProcess = await this.#getFreshTaskRunProcess(payload, messageId);
logger.debug(this.#prefixedMessage(payload, "executing task run"), {
pid: taskRunProcess.pid,
});
const result = await taskRunProcess.executeTaskRun(payload); const result = await taskRunProcess.executeTaskRun(payload);
// Kill the worker if the task was successful or if it's not going to be retried); // Always kill the worker
await taskRunProcess.cleanup(result.ok || result.retry === undefined); await taskRunProcess.cleanup(true);
if (result.ok) { if (result.ok) {
return result; return result;
@@ -568,6 +768,7 @@ class TaskRunProcess {
}); });
private _sender: ZodMessageSender<typeof workerToChildMessages>; private _sender: ZodMessageSender<typeof workerToChildMessages>;
private _child: ChildProcess | undefined; private _child: ChildProcess | undefined;
private _childPid?: number;
private _attemptPromises: Map< private _attemptPromises: Map<
string, string,
{ resolver: (value: TaskRunExecutionResult) => void; rejecter: (err?: any) => void } { resolver: (value: TaskRunExecutionResult) => void; rejecter: (err?: any) => void }
@@ -576,15 +777,23 @@ class TaskRunProcess {
private _currentExecution: TaskRunExecution | undefined; private _currentExecution: TaskRunExecution | undefined;
private _isBeingKilled: boolean = false; private _isBeingKilled: boolean = false;
private _isBeingCancelled: boolean = false; private _isBeingCancelled: boolean = false;
/**
* @deprecated use onTaskRunHeartbeat instead
*/
public onTaskHeartbeat: Evt<string> = new Evt(); public onTaskHeartbeat: Evt<string> = new Evt();
public onExit: Evt<number> = new Evt(); public onTaskRunHeartbeat: Evt<string> = new Evt();
public onExit: Evt<{ code: number | null; signal: NodeJS.Signals | null; pid?: number }> =
new Evt();
public onIsBeingKilled: Evt<number | undefined> = new Evt();
constructor( constructor(
private execution: TaskRunExecution, private runId: string,
private isTest: boolean,
private path: string, private path: string,
private env: NodeJS.ProcessEnv, private env: NodeJS.ProcessEnv,
private metadata: BackgroundWorkerProperties, private metadata: BackgroundWorkerProperties,
private worker: BackgroundWorkerParams private worker: BackgroundWorkerParams,
private messageId?: string
) { ) {
this._sender = new ZodMessageSender({ this._sender = new ZodMessageSender({
schema: workerToChildMessages, schema: workerToChildMessages,
@@ -604,7 +813,7 @@ class TaskRunProcess {
async initialize() { async initialize() {
const fullEnv = { const fullEnv = {
...(this.execution.run.isTest ? { TRIGGER_LOG_LEVEL: "debug" } : {}), ...(this.isTest ? { TRIGGER_LOG_LEVEL: "debug" } : {}),
...this.env, ...this.env,
OTEL_RESOURCE_ATTRIBUTES: JSON.stringify({ OTEL_RESOURCE_ATTRIBUTES: JSON.stringify({
[SemanticInternalAttributes.PROJECT_DIR]: this.worker.projectConfig.projectDir, [SemanticInternalAttributes.PROJECT_DIR]: this.worker.projectConfig.projectDir,
@@ -615,7 +824,7 @@ class TaskRunProcess {
const cwd = dirname(this.path); const cwd = dirname(this.path);
logger.debug(`[${this.execution.run.id}] initializing task run process`, { logger.debug(`[${this.runId}] initializing task run process`, {
env: fullEnv, env: fullEnv,
path: this.path, path: this.path,
cwd, cwd,
@@ -629,6 +838,7 @@ class TaskRunProcess {
? ["--inspect-brk", "--trace-uncaught", "--no-warnings=ExperimentalWarning"] ? ["--inspect-brk", "--trace-uncaught", "--no-warnings=ExperimentalWarning"]
: ["--trace-uncaught", "--no-warnings=ExperimentalWarning"], : ["--trace-uncaught", "--no-warnings=ExperimentalWarning"],
}); });
this._childPid = this._child?.pid;
this._child.on("message", this.#handleMessage.bind(this)); this._child.on("message", this.#handleMessage.bind(this));
this._child.on("exit", this.#handleExit.bind(this)); this._child.on("exit", this.#handleExit.bind(this));
@@ -641,19 +851,28 @@ class TaskRunProcess {
return; return;
} }
logger.debug(`[${this.execution.run.id}] cleaning up task run process`, { kill }); if (kill) {
this._isBeingKilled = true;
this.onIsBeingKilled.post(this._child?.pid);
}
logger.debug(`[${this.runId}] cleaning up task run process`, { kill, pid: this.pid });
await this._sender.send("CLEANUP", { await this._sender.send("CLEANUP", {
flush: true, flush: true,
kill, kill,
}); });
this._isBeingKilled = kill; // FIXME: Something broke READY_TO_DISPOSE. We never receive it, so we always have to kill the process after the timeout below.
if (!kill) {
return;
}
// Set a timeout to kill the child process if it hasn't been killed within 5 seconds // Set a timeout to kill the child process if it hasn't been killed within 5 seconds
setTimeout(() => { setTimeout(() => {
if (this._child && !this._child.killed) { if (this._child && !this._child.killed) {
logger.debug(`[${this.execution.run.id}] killing task run process after timeout`); logger.debug(`[${this.runId}] killing task run process after timeout`, { pid: this.pid });
this._child.kill(); this._child.kill();
} }
@@ -691,24 +910,23 @@ class TaskRunProcess {
return result; return result;
} }
taskRunCompletedNotification(completion: TaskRunExecutionResult, execution: TaskRunExecution) { taskRunCompletedNotification(completion: TaskRunExecutionResult) {
if (!completion.ok && typeof completion.retry !== "undefined") { if (!completion.ok && typeof completion.retry !== "undefined") {
return; return;
} }
if (execution.run.id === this.execution.run.id) { if (completion.id === this.runId) {
// We don't need to notify the task run process if it's the same as the one we're running // We don't need to notify the task run process if it's the same as the one we're running
return; return;
} }
logger.debug(`[${this.execution.run.id}] task run completed notification`, { logger.debug(`[${this.runId}] task run completed notification`, {
completion, completion,
execution,
}); });
this._sender.send("TASK_RUN_COMPLETED_NOTIFICATION", { this._sender.send("TASK_RUN_COMPLETED_NOTIFICATION", {
version: "v2",
completion, completion,
execution,
}); });
} }
@@ -740,14 +958,18 @@ class TaskRunProcess {
break; break;
} }
case "READY_TO_DISPOSE": { case "READY_TO_DISPOSE": {
logger.debug(`[${this.execution.run.id}] task run process is ready to dispose`); logger.debug(`[${this.runId}] task run process is ready to dispose`);
this.#kill(); this.#kill();
break; break;
} }
case "TASK_HEARTBEAT": { case "TASK_HEARTBEAT": {
this.onTaskHeartbeat.post(message.payload.id); if (this.messageId) {
this.onTaskRunHeartbeat.post(this.messageId);
} else {
this.onTaskHeartbeat.post(message.payload.id);
}
break; break;
} }
@@ -757,8 +979,8 @@ class TaskRunProcess {
} }
} }
async #handleExit(code: number) { async #handleExit(code: number | null, signal: NodeJS.Signals | null) {
logger.debug(`[${this.execution.run.id}] task run process exiting`, { code }); logger.debug(`[${this.runId}] handle task run process exit`, { code, signal, pid: this.pid });
// Go through all the attempts currently pending and reject them // Go through all the attempts currently pending and reject them
for (const [id, status] of this._attemptStatuses.entries()) { for (const [id, status] of this._attemptStatuses.entries()) {
@@ -778,12 +1000,12 @@ class TaskRunProcess {
} else if (this._isBeingKilled) { } else if (this._isBeingKilled) {
rejecter(new CleanupProcessError()); rejecter(new CleanupProcessError());
} else { } else {
rejecter(new UnexpectedExitError(code)); rejecter(new UnexpectedExitError(code ?? -1));
} }
} }
} }
this.onExit.post(code); this.onExit.post({ code, signal, pid: this.pid });
} }
#handleLog(data: Buffer) { #handleLog(data: Buffer) {
@@ -823,10 +1045,54 @@ class TaskRunProcess {
} }
#kill() { #kill() {
if (this._child && !this._child.killed) { logger.debug(`[${this.runId}] #kill()`, { pid: this.pid });
logger.debug(`[${this.execution.run.id}] killing task run process`);
if (this._child && !this._child.killed) {
this._child?.kill(); this._child?.kill();
} }
} }
async kill(signal?: number | NodeJS.Signals, timeoutInMs?: number) {
logger.debug(`[${this.runId}] killing task run process`, {
signal,
timeoutInMs,
pid: this.pid,
});
this._isBeingKilled = true;
const killTimeout = this.onExit.waitFor(timeoutInMs);
this.onIsBeingKilled.post(this._child?.pid);
this._child?.kill(signal);
if (timeoutInMs) {
await killTimeout;
}
}
get isBeingKilled() {
return this._isBeingKilled || this._child?.killed;
}
get pid() {
return this._childPid;
}
}
function formatErrorLog(error: TaskRunError) {
switch (error.type) {
case "INTERNAL_ERROR": {
return "";
}
case "STRING_ERROR": {
return `\n\n${chalkError("X Error:")} ${error.raw}\n`;
}
case "CUSTOM_ERROR": {
return `\n\n${chalkError("X Error:")} ${error.raw}\n`;
}
case "BUILT_IN_ERROR": {
return `\n\n${error.stackTrace.replace(/^Error: /, chalkError("X Error: "))}\n`;
}
}
} }
@@ -182,8 +182,17 @@ const handler = new ZodMessageHandler({
_isRunning = false; _isRunning = false;
} }
}, },
TASK_RUN_COMPLETED_NOTIFICATION: async ({ completion, execution }) => { TASK_RUN_COMPLETED_NOTIFICATION: async (payload) => {
devRuntimeManager.resumeTask(completion, execution); switch (payload.version) {
case "v1": {
devRuntimeManager.resumeTask(payload.completion, payload.execution.run.id);
break;
}
case "v2": {
devRuntimeManager.resumeTask(payload.completion, payload.completion.id);
break;
}
}
}, },
CLEANUP: async ({ flush, kill }) => { CLEANUP: async ({ flush, kill }) => {
if (kill) { if (kill) {
@@ -215,7 +224,7 @@ sender.send("TASKS_READY", { tasks: TASK_METADATA }).catch((err) => {
process.title = "trigger-dev-worker"; process.title = "trigger-dev-worker";
async function asyncHeartbeat(initialDelayInSeconds: number = 30, intervalInSeconds: number = 5) { async function asyncHeartbeat(initialDelayInSeconds: number = 30, intervalInSeconds: number = 30) {
async function _doHeartbeat() { async function _doHeartbeat() {
while (true) { while (true) {
if (_isRunning && _execution) { if (_isRunning && _execution) {
@@ -11,6 +11,7 @@ import {
TaskRunBuiltInError, TaskRunBuiltInError,
TaskRunErrorCodes, TaskRunErrorCodes,
TaskRunExecution, TaskRunExecution,
TaskRunExecutionLazyAttemptPayload,
TaskRunExecutionPayload, TaskRunExecutionPayload,
TaskRunExecutionResult, TaskRunExecutionResult,
WaitReason, WaitReason,
@@ -20,31 +21,15 @@ import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
import type { InferSocketMessageSchema } from "@trigger.dev/core/v3/zodSocket"; import type { InferSocketMessageSchema } from "@trigger.dev/core/v3/zodSocket";
import { Evt } from "evt"; import { Evt } from "evt";
import { ChildProcess, fork } from "node:child_process"; import { ChildProcess, fork } from "node:child_process";
import { TaskMetadataParseError, UncaughtExceptionError } from "../common/errors"; import {
CancelledProcessError,
class UnexpectedExitError extends Error { CleanupProcessError,
constructor(public code: number) { GracefulExitTimeoutError,
super(`Unexpected exit with code ${code}`); SigKillTimeoutProcessError,
TaskMetadataParseError,
this.name = "UnexpectedExitError"; UncaughtExceptionError,
} UnexpectedExitError,
} } from "../common/errors";
class CleanupProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CleanupProcessError";
}
}
class CancelledProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CancelledProcessError";
}
}
type BackgroundWorkerParams = { type BackgroundWorkerParams = {
env: Record<string, string>; env: Record<string, string>;
@@ -56,7 +41,11 @@ type BackgroundWorkerParams = {
export class ProdBackgroundWorker { export class ProdBackgroundWorker {
private _initialized: boolean = false; private _initialized: boolean = false;
/**
* @deprecated use onTaskRunHeartbeat instead
*/
public onTaskHeartbeat: Evt<string> = new Evt(); public onTaskHeartbeat: Evt<string> = new Evt();
public onTaskRunHeartbeat: Evt<string> = new Evt();
public onWaitForBatch: Evt< public onWaitForBatch: Evt<
InferSocketMessageSchema<typeof ProdChildToWorkerMessages, "WAIT_FOR_BATCH"> InferSocketMessageSchema<typeof ProdChildToWorkerMessages, "WAIT_FOR_BATCH">
@@ -74,11 +63,24 @@ export class ProdBackgroundWorker {
public onReadyForCheckpoint = Evt.create<{ version?: "v1" }>(); public onReadyForCheckpoint = Evt.create<{ version?: "v1" }>();
public onCancelCheckpoint = Evt.create<{ version?: "v1" | "v2"; reason?: WaitReason }>(); public onCancelCheckpoint = Evt.create<{ version?: "v1" | "v2"; reason?: WaitReason }>();
public onCreateTaskRunAttempt = Evt.create<{ version?: "v1"; runId: string }>();
public attemptCreatedNotification = Evt.create<
| {
success: false;
reason?: string;
}
| {
success: true;
execution: ProdTaskRunExecution;
}
>();
private _onClose: Evt<void> = new Evt(); private _onClose: Evt<void> = new Evt();
public tasks: Array<TaskMetadataWithFilePath> = []; public tasks: Array<TaskMetadataWithFilePath> = [];
_taskRunProcess: TaskRunProcess | undefined; _taskRunProcess: TaskRunProcess | undefined;
private _taskRunProcessesBeingKilled: Map<number, TaskRunProcess> = new Map();
private _closed: boolean = false; private _closed: boolean = false;
@@ -87,7 +89,9 @@ export class ProdBackgroundWorker {
private params: BackgroundWorkerParams private params: BackgroundWorkerParams
) {} ) {}
async close() { async close(gracefulExitTimeoutElapsed = false) {
console.log("Closing worker", { gracefulExitTimeoutElapsed, closed: this._closed });
if (this._closed) { if (this._closed) {
return; return;
} }
@@ -95,9 +99,35 @@ export class ProdBackgroundWorker {
this._closed = true; this._closed = true;
this.onTaskHeartbeat.detach(); this.onTaskHeartbeat.detach();
this.onTaskRunHeartbeat.detach();
// We need to close the task run process // We need to close the task run process
await this._taskRunProcess?.cleanup(true); await this._taskRunProcess?.cleanup(true, gracefulExitTimeoutElapsed);
}
async #killTaskRunProcess(flush = true, initialSignal: number | NodeJS.Signals = "SIGTERM") {
console.log("Killing task run process", { flush, initialSignal, closed: this._closed });
if (this._closed || !this._taskRunProcess) {
return;
}
if (flush) {
await this.flushTelemetry();
}
const currentTaskRunProcess = this._taskRunProcess;
// Try graceful exit but don't wait. We limit the amount of processes during creation instead.
this.#tryGracefulExit(currentTaskRunProcess, true, initialSignal).catch((error) => {
console.error("Error while trying graceful exit", error);
});
console.log("Killed task run process, setting closed to true", {
closed: this._closed,
pid: currentTaskRunProcess.pid,
});
this._closed = true;
} }
async flushTelemetry() { async flushTelemetry() {
@@ -193,83 +223,191 @@ export class ProdBackgroundWorker {
// We need to notify all the task run processes that a task run has completed, // We need to notify all the task run processes that a task run has completed,
// in case they are waiting for it through triggerAndWait // in case they are waiting for it through triggerAndWait
async taskRunCompletedNotification( async taskRunCompletedNotification(completion: TaskRunExecutionResult) {
completion: TaskRunExecutionResult, this._taskRunProcess?.taskRunCompletedNotification(completion);
execution: TaskRunExecution
) {
this._taskRunProcess?.taskRunCompletedNotification(completion, execution);
} }
async waitCompletedNotification() { async waitCompletedNotification() {
this._taskRunProcess?.waitCompletedNotification(); this._taskRunProcess?.waitCompletedNotification();
} }
async #initializeTaskRunProcess(payload: ProdTaskRunExecutionPayload): Promise<TaskRunProcess> { async #getFreshTaskRunProcess(
payload: ProdTaskRunExecutionPayload,
messageId?: string
): Promise<TaskRunProcess> {
const metadata = this.getMetadata( const metadata = this.getMetadata(
payload.execution.worker.id, payload.execution.worker.id,
payload.execution.worker.version payload.execution.worker.version
); );
if (!this._taskRunProcess) { console.log("Getting fresh task run process, setting closed to false", {
const taskRunProcess = new TaskRunProcess( closed: this._closed,
payload.execution, });
this.path, this._closed = false;
{
...this.params.env,
...(payload.environment ?? {}),
},
metadata,
this.params
);
taskRunProcess.onExit.attach(() => { await this.#killCurrentTaskRunProcessBeforeAttempt();
const taskRunProcess = new TaskRunProcess(
payload.execution.run.id,
payload.execution.run.isTest,
this.path,
{
...this.params.env,
...(payload.environment ?? {}),
},
metadata,
this.params,
messageId
);
taskRunProcess.onExit.attach(({ pid }) => {
console.log("Task run process exited", { pid });
// Only delete the task run process if the pid matches
if (this._taskRunProcess?.pid === pid) {
this._taskRunProcess = undefined; this._taskRunProcess = undefined;
}); }
taskRunProcess.onTaskHeartbeat.attach((id) => { if (pid) {
this.onTaskHeartbeat.post(id); this._taskRunProcessesBeingKilled.delete(pid);
}); }
});
taskRunProcess.onWaitForBatch.attach((message) => { taskRunProcess.onIsBeingKilled.attach((taskRunProcess) => {
this.onWaitForBatch.post(message); if (taskRunProcess?.pid) {
}); this._taskRunProcessesBeingKilled.set(taskRunProcess.pid, taskRunProcess);
}
});
taskRunProcess.onWaitForDuration.attach((message) => { taskRunProcess.onTaskHeartbeat.attach((id) => {
this.onWaitForDuration.post(message); this.onTaskHeartbeat.post(id);
}); });
taskRunProcess.onWaitForTask.attach((message) => { taskRunProcess.onTaskRunHeartbeat.attach((id) => {
this.onWaitForTask.post(message); this.onTaskRunHeartbeat.post(id);
}); });
taskRunProcess.onReadyForCheckpoint.attach((message) => { taskRunProcess.onWaitForBatch.attach((message) => {
this.onReadyForCheckpoint.post(message); this.onWaitForBatch.post(message);
}); });
taskRunProcess.onCancelCheckpoint.attach((message) => { taskRunProcess.onWaitForDuration.attach((message) => {
this.onCancelCheckpoint.post(message); this.onWaitForDuration.post(message);
}); });
// Notify down the chain taskRunProcess.onWaitForTask.attach((message) => {
this.preCheckpointNotification.attach((message) => { this.onWaitForTask.post(message);
taskRunProcess.preCheckpointNotification.post(message); });
});
this.checkpointCanceledNotification.attach((message) => {
taskRunProcess.checkpointCanceledNotification.post(message);
});
await taskRunProcess.initialize(); taskRunProcess.onReadyForCheckpoint.attach((message) => {
this.onReadyForCheckpoint.post(message);
});
this._taskRunProcess = taskRunProcess; taskRunProcess.onCancelCheckpoint.attach((message) => {
} this.onCancelCheckpoint.post(message);
});
// Notify down the chain
this.preCheckpointNotification.attach((message) => {
taskRunProcess.preCheckpointNotification.post(message);
});
this.checkpointCanceledNotification.attach((message) => {
taskRunProcess.checkpointCanceledNotification.post(message);
});
await taskRunProcess.initialize();
this._taskRunProcess = taskRunProcess;
return this._taskRunProcess; return this._taskRunProcess;
} }
// We need to fork the process before we can execute any tasks async forceKillOldTaskRunProcesses() {
async executeTaskRun(payload: ProdTaskRunExecutionPayload): Promise<TaskRunExecutionResult> { for (const taskRunProcess of this._taskRunProcessesBeingKilled.values()) {
try {
await taskRunProcess.kill("SIGKILL");
} catch (error) {
console.error("Error while force killing old task run processes", error);
}
}
}
async #killCurrentTaskRunProcessBeforeAttempt() {
console.log("killCurrentTaskRunProcessBeforeAttempt()", {
hasTaskRunProcess: !!this._taskRunProcess,
});
if (!this._taskRunProcess) {
return;
}
const currentTaskRunProcess = this._taskRunProcess;
console.log("Killing current task run process", {
isBeingKilled: currentTaskRunProcess?.isBeingKilled,
totalBeingKilled: this._taskRunProcessesBeingKilled.size,
});
if (currentTaskRunProcess.isBeingKilled) {
if (this._taskRunProcessesBeingKilled.size > 1) {
await this.#tryGracefulExit(currentTaskRunProcess);
} else {
// If there's only one or none being killed, don't do anything so we can create a fresh one in parallel
}
} else {
// It's not being killed, so kill it
if (this._taskRunProcessesBeingKilled.size > 0) {
await this.#tryGracefulExit(currentTaskRunProcess);
} else {
// There's none being killed yet, so we can kill it without waiting. We still set a timeout to kill it forcefully just in case it sticks around.
currentTaskRunProcess.kill("SIGTERM", 5_000).catch(() => {});
}
}
}
async #tryGracefulExit(
taskRunProcess: TaskRunProcess,
kill = false,
initialSignal: number | NodeJS.Signals = "SIGTERM"
) {
try { try {
const taskRunProcess = await this.#initializeTaskRunProcess(payload); const initialExit = taskRunProcess.onExit.waitFor(5_000);
if (kill) {
taskRunProcess.kill(initialSignal);
}
await initialExit;
} catch (error) {
console.error("TaskRunProcess graceful kill timeout exceeded", error);
this.#tryForcefulExit(taskRunProcess);
}
}
async #tryForcefulExit(taskRunProcess: TaskRunProcess) {
try {
const forcedKill = taskRunProcess.onExit.waitFor(5_000);
taskRunProcess.kill("SIGKILL");
await forcedKill;
} catch (error) {
console.error("TaskRunProcess forced kill timeout exceeded", error);
throw new SigKillTimeoutProcessError();
}
}
// We need to fork the process before we can execute any tasks, use a fresh process for each execution
async executeTaskRun(
payload: ProdTaskRunExecutionPayload,
messageId?: string
): Promise<TaskRunExecutionResult> {
try {
const taskRunProcess = await this.#getFreshTaskRunProcess(payload, messageId);
console.log("executing task run", {
attempt: payload.execution.attempt.id,
taskRunPid: taskRunProcess.pid,
});
const result = await taskRunProcess.executeTaskRun(payload); const result = await taskRunProcess.executeTaskRun(payload);
@@ -326,6 +464,31 @@ export class ProdBackgroundWorker {
}; };
} }
if (e instanceof SigKillTimeoutProcessError) {
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_PROCESS_SIGKILL_TIMEOUT,
},
};
}
if (e instanceof GracefulExitTimeoutError) {
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.GRACEFUL_EXIT_TIMEOUT,
message: "Worker process killed while attempt in progress.",
},
};
}
return { return {
id: payload.execution.attempt.id, id: payload.execution.attempt.id,
ok: false, ok: false,
@@ -335,11 +498,52 @@ export class ProdBackgroundWorker {
code: TaskRunErrorCodes.TASK_EXECUTION_FAILED, code: TaskRunErrorCodes.TASK_EXECUTION_FAILED,
}, },
}; };
} finally {
await this.#killTaskRunProcess();
} }
} }
async cancelAttempt(attemptId: string) { async cancelAttempt(attemptId: string) {
await this._taskRunProcess?.cancel(); if (!this._taskRunProcess) {
console.error("No task run process to cancel attempt", { attemptId });
return;
}
await this._taskRunProcess.cancel();
}
async executeTaskRunLazyAttempt(payload: TaskRunExecutionLazyAttemptPayload) {
// Post to coordinator
this.onCreateTaskRunAttempt.post({ runId: payload.runId });
let execution: ProdTaskRunExecution;
try {
// ..and wait for response
const attemptCreated = await this.attemptCreatedNotification.waitFor(30_000);
if (!attemptCreated.success) {
throw new Error(
`Failed to create attempt${attemptCreated.reason ? `: ${attemptCreated.reason}` : ""}`
);
}
execution = attemptCreated.execution;
} catch (error) {
console.error("Error while creating attempt", error);
throw new Error(`Failed to create task run attempt: ${error}`);
}
const completion = await this.executeTaskRun(
{
execution,
traceContext: payload.traceContext,
environment: payload.environment,
},
payload.messageId
);
return { execution, completion };
} }
async #correctError( async #correctError(
@@ -359,6 +563,7 @@ class TaskRunProcess {
typeof ProdWorkerToChildMessages typeof ProdWorkerToChildMessages
>; >;
private _child?: ChildProcess; private _child?: ChildProcess;
private _childPid?: number;
private _attemptPromises: Map< private _attemptPromises: Map<
string, string,
@@ -368,9 +573,16 @@ class TaskRunProcess {
private _currentExecution: TaskRunExecution | undefined; private _currentExecution: TaskRunExecution | undefined;
private _isBeingKilled: boolean = false; private _isBeingKilled: boolean = false;
private _isBeingCancelled: boolean = false; private _isBeingCancelled: boolean = false;
private _gracefulExitTimeoutElapsed: boolean = false;
/**
* @deprecated use onTaskRunHeartbeat instead
*/
public onTaskHeartbeat: Evt<string> = new Evt(); public onTaskHeartbeat: Evt<string> = new Evt();
public onExit: Evt<number> = new Evt(); public onTaskRunHeartbeat: Evt<string> = new Evt();
public onExit: Evt<{ code: number | null; signal: NodeJS.Signals | null; pid?: number }> =
new Evt();
public onIsBeingKilled: Evt<TaskRunProcess> = new Evt();
public onWaitForBatch: Evt< public onWaitForBatch: Evt<
InferSocketMessageSchema<typeof ProdChildToWorkerMessages, "WAIT_FOR_BATCH"> InferSocketMessageSchema<typeof ProdChildToWorkerMessages, "WAIT_FOR_BATCH">
@@ -389,18 +601,20 @@ class TaskRunProcess {
public onCancelCheckpoint = Evt.create<{ version?: "v1" | "v2"; reason?: WaitReason }>(); public onCancelCheckpoint = Evt.create<{ version?: "v1" | "v2"; reason?: WaitReason }>();
constructor( constructor(
private execution: ProdTaskRunExecution, private runId: string,
private isTest: boolean,
private path: string, private path: string,
private env: NodeJS.ProcessEnv, private env: NodeJS.ProcessEnv,
private metadata: BackgroundWorkerProperties, private metadata: BackgroundWorkerProperties,
private worker: BackgroundWorkerParams private worker: BackgroundWorkerParams,
private messageId?: string
) {} ) {}
async initialize() { async initialize() {
this._child = fork(this.path, { this._child = fork(this.path, {
stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"], stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"],
env: { env: {
...(this.execution.run.isTest ? { TRIGGER_LOG_LEVEL: "debug" } : {}), ...(this.isTest ? { TRIGGER_LOG_LEVEL: "debug" } : {}),
...this.env, ...this.env,
OTEL_RESOURCE_ATTRIBUTES: JSON.stringify({ OTEL_RESOURCE_ATTRIBUTES: JSON.stringify({
[SemanticInternalAttributes.PROJECT_DIR]: this.worker.projectConfig.projectDir, [SemanticInternalAttributes.PROJECT_DIR]: this.worker.projectConfig.projectDir,
@@ -408,6 +622,7 @@ class TaskRunProcess {
...(this.worker.debugOtel ? { OTEL_LOG_LEVEL: "debug" } : {}), ...(this.worker.debugOtel ? { OTEL_LOG_LEVEL: "debug" } : {}),
}, },
}); });
this._childPid = this._child?.pid;
this._ipc = new ZodIpcConnection({ this._ipc = new ZodIpcConnection({
listenSchema: ProdChildToWorkerMessages, listenSchema: ProdChildToWorkerMessages,
@@ -439,7 +654,11 @@ class TaskRunProcess {
process.exit(0); process.exit(0);
}, },
TASK_HEARTBEAT: async (message) => { TASK_HEARTBEAT: async (message) => {
this.onTaskHeartbeat.post(message.id); if (this.messageId) {
this.onTaskRunHeartbeat.post(this.messageId);
} else {
this.onTaskHeartbeat.post(message.id);
}
}, },
TASKS_READY: async (message) => {}, TASKS_READY: async (message) => {},
WAIT_FOR_TASK: async (message) => { WAIT_FOR_TASK: async (message) => {
@@ -513,17 +732,38 @@ class TaskRunProcess {
await this.cleanup(true); await this.cleanup(true);
} }
async cleanup(kill: boolean = false) { async cleanup(kill = false, gracefulExitTimeoutElapsed = false) {
console.log("cleanup()", { kill, gracefulExitTimeoutElapsed });
if (kill && this._isBeingKilled) { if (kill && this._isBeingKilled) {
return; return;
} }
this._isBeingKilled = kill; if (kill) {
this._isBeingKilled = true;
this.onIsBeingKilled.post(this);
}
const killChildProcess = gracefulExitTimeoutElapsed && !!this._currentExecution;
// Kill parent unless graceful exit timeout has elapsed and we're in the middle of an execution
const killParentProcess = kill && !killChildProcess;
console.log("Cleaning up task run process", {
killChildProcess,
killParentProcess,
});
await this._ipc?.sendWithAck("CLEANUP", { await this._ipc?.sendWithAck("CLEANUP", {
flush: true, flush: true,
kill, kill: killParentProcess,
}); });
if (killChildProcess) {
this._gracefulExitTimeoutElapsed = true;
// Kill the child process
await this.kill("SIGKILL");
}
} }
async executeTaskRun(payload: TaskRunExecutionPayload): Promise<TaskRunExecutionResult> { async executeTaskRun(payload: TaskRunExecutionPayload): Promise<TaskRunExecutionResult> {
@@ -559,15 +799,15 @@ class TaskRunProcess {
return result; return result;
} }
taskRunCompletedNotification(completion: TaskRunExecutionResult, execution: TaskRunExecution) { taskRunCompletedNotification(completion: TaskRunExecutionResult) {
if (!completion.ok && typeof completion.retry !== "undefined") { if (!completion.ok && typeof completion.retry !== "undefined") {
return; return;
} }
if (this._child?.connected && !this._isBeingKilled && !this._child.killed) { if (this._child?.connected && !this._isBeingKilled && !this._child.killed) {
this._ipc?.send("TASK_RUN_COMPLETED_NOTIFICATION", { this._ipc?.send("TASK_RUN_COMPLETED_NOTIFICATION", {
version: "v2",
completion, completion,
execution,
}); });
} }
} }
@@ -578,10 +818,14 @@ class TaskRunProcess {
} }
} }
async #handleExit(code: number) { async #handleExit(code: number | null, signal: NodeJS.Signals | null) {
console.log("handling child exit", { code, signal });
// Go through all the attempts currently pending and reject them // Go through all the attempts currently pending and reject them
for (const [id, status] of this._attemptStatuses.entries()) { for (const [id, status] of this._attemptStatuses.entries()) {
if (status === "PENDING") { if (status === "PENDING") {
console.log("found pending attempt", { id });
this._attemptStatuses.set(id, "REJECTED"); this._attemptStatuses.set(id, "REJECTED");
const attemptPromise = this._attemptPromises.get(id); const attemptPromise = this._attemptPromises.get(id);
@@ -594,15 +838,18 @@ class TaskRunProcess {
if (this._isBeingCancelled) { if (this._isBeingCancelled) {
rejecter(new CancelledProcessError()); rejecter(new CancelledProcessError());
} else if (this._gracefulExitTimeoutElapsed) {
// Order matters, this has to be before the graceful exit timeout
rejecter(new GracefulExitTimeoutError());
} else if (this._isBeingKilled) { } else if (this._isBeingKilled) {
rejecter(new CleanupProcessError()); rejecter(new CleanupProcessError());
} else { } else {
rejecter(new UnexpectedExitError(code)); rejecter(new UnexpectedExitError(code ?? -1));
} }
} }
} }
this.onExit.post(code); this.onExit.post({ code, signal, pid: this.pid });
} }
#handleLog(data: Buffer) { #handleLog(data: Buffer) {
@@ -635,9 +882,24 @@ class TaskRunProcess {
); );
} }
#kill() { async kill(signal?: number | NodeJS.Signals, timeoutInMs?: number) {
if (this._child && !this._child.killed) { this._isBeingKilled = true;
this._child?.kill();
const killTimeout = this.onExit.waitFor(timeoutInMs);
this.onIsBeingKilled.post(this);
this._child?.kill(signal);
if (timeoutInMs) {
await killTimeout;
} }
} }
get isBeingKilled() {
return this._isBeingKilled || this._child?.killed;
}
get pid() {
return this._childPid;
}
} }
+315 -152
View File
@@ -5,6 +5,7 @@ import {
PreStopCauses, PreStopCauses,
ProdWorkerToCoordinatorMessages, ProdWorkerToCoordinatorMessages,
TaskResource, TaskResource,
TaskRunFailedExecutionResult,
WaitReason, WaitReason,
} from "@trigger.dev/core/v3"; } from "@trigger.dev/core/v3";
import { ZodSocketConnection } from "@trigger.dev/core/v3/zodSocket"; import { ZodSocketConnection } from "@trigger.dev/core/v3/zodSocket";
@@ -60,104 +61,7 @@ class ProdWorker {
process.on("SIGTERM", this.#handleSignal.bind(this, "SIGTERM")); process.on("SIGTERM", this.#handleSignal.bind(this, "SIGTERM"));
this.#coordinatorSocket = this.#createCoordinatorSocket(COORDINATOR_HOST); this.#coordinatorSocket = this.#createCoordinatorSocket(COORDINATOR_HOST);
this.#backgroundWorker = this.#createBackgroundWorker();
this.#backgroundWorker = new ProdBackgroundWorker("worker.js", {
projectConfig: __PROJECT_CONFIG__,
env: {
...gatherProcessEnv(),
TRIGGER_API_URL: this.apiUrl,
TRIGGER_SECRET_KEY: this.apiKey,
OTEL_EXPORTER_OTLP_ENDPOINT:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318",
},
contentHash: this.contentHash,
});
this.#backgroundWorker.onTaskHeartbeat.attach((attemptFriendlyId) => {
// TODO: Switch to .send() once coordinator uses zod handler for all messages
this.#coordinatorSocket.socket.emit("TASK_HEARTBEAT", { version: "v1", attemptFriendlyId });
});
this.#backgroundWorker.onReadyForCheckpoint.attach(async (message) => {
// Flush before checkpointing so we don't flush the same spans again after restore
await this.#backgroundWorker.flushTelemetry();
this.#coordinatorSocket.socket.emit("READY_FOR_CHECKPOINT", { version: "v1" });
});
// Currently, this is only used for duration waits. Might need adjusting for other use cases.
this.#backgroundWorker.onCancelCheckpoint.attach(async (message) => {
logger.log("onCancelCheckpoint", { message });
const { checkpointCanceled } = await this.#coordinatorSocket.socket.emitWithAck(
"CANCEL_CHECKPOINT",
{
version: "v2",
reason: message.reason,
}
);
if (checkpointCanceled) {
if (message.reason === "WAIT_FOR_DURATION") {
// Worker will resume immediately
this.paused = false;
this.nextResumeAfter = undefined;
this.waitForPostStart = false;
}
}
this.#backgroundWorker.checkpointCanceledNotification.post({ checkpointCanceled });
});
this.#backgroundWorker.onWaitForDuration.attach(async (message) => {
if (!this.attemptFriendlyId) {
logger.error("Failed to send wait message, attempt friendly ID not set", { message });
return;
}
const { willCheckpointAndRestore } = await this.#coordinatorSocket.socket.emitWithAck(
"WAIT_FOR_DURATION",
{
...message,
attemptFriendlyId: this.attemptFriendlyId,
}
);
this.#prepareForWait("WAIT_FOR_DURATION", willCheckpointAndRestore);
});
this.#backgroundWorker.onWaitForTask.attach(async (message) => {
if (!this.attemptFriendlyId) {
logger.error("Failed to send wait message, attempt friendly ID not set", { message });
return;
}
const { willCheckpointAndRestore } = await this.#coordinatorSocket.socket.emitWithAck(
"WAIT_FOR_TASK",
{
...message,
attemptFriendlyId: this.attemptFriendlyId,
}
);
this.#prepareForWait("WAIT_FOR_TASK", willCheckpointAndRestore);
});
this.#backgroundWorker.onWaitForBatch.attach(async (message) => {
if (!this.attemptFriendlyId) {
logger.error("Failed to send wait message, attempt friendly ID not set", { message });
return;
}
const { willCheckpointAndRestore } = await this.#coordinatorSocket.socket.emitWithAck(
"WAIT_FOR_BATCH",
{
...message,
attemptFriendlyId: this.attemptFriendlyId,
}
);
this.#prepareForWait("WAIT_FOR_BATCH", willCheckpointAndRestore);
});
this.#httpPort = port; this.#httpPort = port;
this.#httpServer = this.#createHttpServer(); this.#httpServer = this.#createHttpServer();
@@ -167,6 +71,8 @@ class ProdWorker {
logger.log("Received signal", { signal }); logger.log("Received signal", { signal });
if (signal === "SIGTERM") { if (signal === "SIGTERM") {
let gracefulExitTimeoutElapsed = false;
if (this.executing) { if (this.executing) {
const terminationGracePeriodSeconds = 60 * 60; const terminationGracePeriodSeconds = 60 * 60;
@@ -176,21 +82,27 @@ class ProdWorker {
// Wait for termination grace period minus 5s to give cleanup a chance to complete // Wait for termination grace period minus 5s to give cleanup a chance to complete
await setTimeout(terminationGracePeriodSeconds * 1000 - 5000); await setTimeout(terminationGracePeriodSeconds * 1000 - 5000);
gracefulExitTimeoutElapsed = true;
logger.log("Termination timeout reached, exiting gracefully."); logger.log("Termination timeout reached, exiting gracefully.");
} else { } else {
logger.log("Not executing, exiting immediately."); logger.log("Not executing, exiting immediately.");
} }
await this.#exitGracefully(); await this.#exitGracefully(gracefulExitTimeoutElapsed);
return;
} }
logger.log("Unhandled signal", { signal }); logger.log("Unhandled signal", { signal });
} }
async #exitGracefully() { async #exitGracefully(gracefulExitTimeoutElapsed = false) {
await this.#backgroundWorker.close(); await this.#backgroundWorker.close(gracefulExitTimeoutElapsed);
process.exit(0);
if (!gracefulExitTimeoutElapsed) {
// TODO: Maybe add a sensible timeout instead of a conditional to avoid zombies
process.exit(0);
}
} }
async #reconnect(isPostStart = false, reconnectImmediately = false) { async #reconnect(isPostStart = false, reconnectImmediately = false) {
@@ -222,12 +134,169 @@ class ProdWorker {
}); });
} }
} catch (error) { } catch (error) {
logger.error("taskinfo read error during reconnect", { error }); logger.error("taskinfo read error during reconnect", {
error: error instanceof Error ? error.message : error,
});
} finally { } finally {
this.#coordinatorSocket = this.#createCoordinatorSocket(coordinatorHost); this.#coordinatorSocket = this.#createCoordinatorSocket(coordinatorHost);
} }
} }
#createBackgroundWorker() {
const backgroundWorker = new ProdBackgroundWorker("worker.js", {
projectConfig: __PROJECT_CONFIG__,
env: {
...gatherProcessEnv(),
TRIGGER_API_URL: this.apiUrl,
TRIGGER_SECRET_KEY: this.apiKey,
OTEL_EXPORTER_OTLP_ENDPOINT:
process.env.OTEL_EXPORTER_OTLP_ENDPOINT ?? "http://0.0.0.0:4318",
},
contentHash: this.contentHash,
});
backgroundWorker.onTaskHeartbeat.attach((attemptFriendlyId) => {
// TODO: Switch to .send() once coordinator uses zod handler for all messages
this.#coordinatorSocket.socket.emit("TASK_HEARTBEAT", { version: "v1", attemptFriendlyId });
});
backgroundWorker.onTaskRunHeartbeat.attach((runId) => {
this.#coordinatorSocket.socket.emit("TASK_RUN_HEARTBEAT", { version: "v1", runId });
});
// Currently, this is only used for duration waits
backgroundWorker.onReadyForCheckpoint.attach(async (message) => {
await this.#prepareForCheckpoint();
this.#coordinatorSocket.socket.emit("READY_FOR_CHECKPOINT", { version: "v1" });
});
// Currently, this is only used for duration waits. Might need adjusting for other use cases.
backgroundWorker.onCancelCheckpoint.attach(async (message) => {
logger.log("onCancelCheckpoint", { message });
const { checkpointCanceled } = await this.#coordinatorSocket.socket.emitWithAck(
"CANCEL_CHECKPOINT",
{
version: "v2",
reason: message.reason,
}
);
logger.log("onCancelCheckpoint coordinator response", { checkpointCanceled });
if (checkpointCanceled) {
if (message.reason === "WAIT_FOR_DURATION") {
// Worker will resume immediately
this.paused = false;
this.nextResumeAfter = undefined;
this.waitForPostStart = false;
}
}
backgroundWorker.checkpointCanceledNotification.post({ checkpointCanceled });
});
backgroundWorker.onCreateTaskRunAttempt.attach(async (message) => {
logger.log("onCreateTaskRunAttempt()", { message });
const createAttempt = await this.#coordinatorSocket.socket.emitWithAck(
"CREATE_TASK_RUN_ATTEMPT",
{
version: "v1",
runId: message.runId,
}
);
if (!createAttempt.success) {
backgroundWorker.attemptCreatedNotification.post({
success: false,
reason: createAttempt.reason,
});
return;
}
backgroundWorker.attemptCreatedNotification.post({
success: true,
execution: createAttempt.executionPayload.execution,
});
});
backgroundWorker.attemptCreatedNotification.attach((message) => {
if (!message.success) {
return;
}
// Workers with lazy attempt support set their friendly ID here
this.attemptFriendlyId = message.execution.attempt.id;
});
backgroundWorker.onWaitForDuration.attach(async (message) => {
if (!this.attemptFriendlyId) {
logger.error("Failed to send wait message, attempt friendly ID not set", { message });
this.#emitUnrecoverableError(
"NoAttemptId",
"Attempt ID not set before waiting for duration"
);
return;
}
const { willCheckpointAndRestore } = await this.#coordinatorSocket.socket.emitWithAck(
"WAIT_FOR_DURATION",
{
...message,
attemptFriendlyId: this.attemptFriendlyId,
}
);
this.#prepareForWait("WAIT_FOR_DURATION", willCheckpointAndRestore);
});
backgroundWorker.onWaitForTask.attach(async (message) => {
if (!this.attemptFriendlyId) {
logger.error("Failed to send wait message, attempt friendly ID not set", { message });
this.#emitUnrecoverableError("NoAttemptId", "Attempt ID not set before waiting for task");
return;
}
const { willCheckpointAndRestore } = await this.#coordinatorSocket.socket.emitWithAck(
"WAIT_FOR_TASK",
{
...message,
attemptFriendlyId: this.attemptFriendlyId,
}
);
this.#prepareForWait("WAIT_FOR_TASK", willCheckpointAndRestore);
});
backgroundWorker.onWaitForBatch.attach(async (message) => {
if (!this.attemptFriendlyId) {
logger.error("Failed to send wait message, attempt friendly ID not set", { message });
this.#emitUnrecoverableError("NoAttemptId", "Attempt ID not set before waiting for batch");
return;
}
const { willCheckpointAndRestore } = await this.#coordinatorSocket.socket.emitWithAck(
"WAIT_FOR_BATCH",
{
...message,
attemptFriendlyId: this.attemptFriendlyId,
}
);
this.#prepareForWait("WAIT_FOR_BATCH", willCheckpointAndRestore);
});
return backgroundWorker;
}
async #prepareForWait(reason: WaitReason, willCheckpointAndRestore: boolean) { async #prepareForWait(reason: WaitReason, willCheckpointAndRestore: boolean) {
logger.log(`prepare for ${reason}`, { willCheckpointAndRestore }); logger.log(`prepare for ${reason}`, { willCheckpointAndRestore });
@@ -239,9 +308,8 @@ class ProdWorker {
this.waitForPostStart = true; this.waitForPostStart = true;
if (reason === "WAIT_FOR_TASK" || reason === "WAIT_FOR_BATCH") { if (reason === "WAIT_FOR_TASK" || reason === "WAIT_FOR_BATCH") {
// Flush before checkpointing so we don't flush the same spans again after restore
// Duration waits do this via the "ready for checkpoint" event instead // Duration waits do this via the "ready for checkpoint" event instead
await this.#backgroundWorker.flushTelemetry(); await this.#prepareForCheckpoint();
} }
} }
} }
@@ -256,18 +324,36 @@ class ProdWorker {
} }
await this.#exitGracefully(); await this.#exitGracefully();
return;
} }
// Clear state for next execution
this.paused = false;
this.waitForPostStart = false;
this.executing = false; this.executing = false;
this.attemptFriendlyId = undefined; this.attemptFriendlyId = undefined;
if (willCheckpointAndRestore) { if (willCheckpointAndRestore) {
this.waitForPostStart = true; this.waitForPostStart = true;
// We already flush after completion, so we don't need to do it here
this.#prepareForCheckpoint(false);
this.#coordinatorSocket.socket.emit("READY_FOR_CHECKPOINT", { version: "v1" }); this.#coordinatorSocket.socket.emit("READY_FOR_CHECKPOINT", { version: "v1" });
return; return;
} }
} }
async #prepareForCheckpoint(flush = true) {
if (flush) {
// Flush before checkpointing so we don't flush the same spans again after restore
await this.#backgroundWorker.flushTelemetry();
}
// Kill the previous worker process to prevent large checkpoints
await this.#backgroundWorker.forceKillOldTaskRunProcesses();
}
#resumeAfterDuration() { #resumeAfterDuration() {
this.paused = false; this.paused = false;
this.nextResumeAfter = undefined; this.nextResumeAfter = undefined;
@@ -303,11 +389,8 @@ class ProdWorker {
extraHeaders["x-trigger-attempt-friendly-id"] = this.attemptFriendlyId; extraHeaders["x-trigger-attempt-friendly-id"] = this.attemptFriendlyId;
} }
logger.log("connecting to coordinator", { logger.log(`connecting to coordinator: ${host}:${COORDINATOR_PORT}`);
host, logger.debug(`connecting with extra headers`, { extraHeaders });
port: COORDINATOR_PORT,
extraHeaders,
});
const coordinatorConnection = new ZodSocketConnection({ const coordinatorConnection = new ZodSocketConnection({
namespace: "prod-worker", namespace: "prod-worker",
@@ -317,28 +400,14 @@ class ProdWorker {
serverMessages: CoordinatorToProdWorkerMessages, serverMessages: CoordinatorToProdWorkerMessages,
extraHeaders, extraHeaders,
handlers: { handlers: {
RESUME_AFTER_DEPENDENCY: async (message) => { RESUME_AFTER_DEPENDENCY: async ({ completions }) => {
if (!this.paused) { if (!this.paused) {
logger.error("worker not paused", { logger.error("Failed to resume after dependency: Worker not paused");
completions: message.completions,
executions: message.executions,
});
return; return;
} }
if (message.completions.length !== message.executions.length) { if (completions.length === 0) {
logger.error("did not receive the same number of completions and executions", { logger.error("Failed to resume after dependency: No completions");
completions: message.completions,
executions: message.executions,
});
return;
}
if (message.completions.length === 0 || message.executions.length === 0) {
logger.error("no completions or executions", {
completions: message.completions,
executions: message.executions,
});
return; return;
} }
@@ -346,17 +415,19 @@ class ProdWorker {
this.nextResumeAfter !== "WAIT_FOR_TASK" && this.nextResumeAfter !== "WAIT_FOR_TASK" &&
this.nextResumeAfter !== "WAIT_FOR_BATCH" this.nextResumeAfter !== "WAIT_FOR_BATCH"
) { ) {
logger.error("not waiting to resume after dependency", { logger.error("Failed to resume after dependency: Invalid next resume", {
nextResumeAfter: this.nextResumeAfter, nextResumeAfter: this.nextResumeAfter,
}); });
return; return;
} }
if (this.nextResumeAfter === "WAIT_FOR_TASK" && message.completions.length > 1) { if (this.nextResumeAfter === "WAIT_FOR_TASK" && completions.length > 1) {
logger.error("waiting for single task but got multiple completions", { logger.error(
completions: message.completions, "Failed to resume after dependency: Waiting for single task but got multiple completions",
executions: message.executions, {
}); completions: completions,
}
);
return; return;
} }
@@ -364,13 +435,12 @@ class ProdWorker {
this.nextResumeAfter = undefined; this.nextResumeAfter = undefined;
this.waitForPostStart = false; this.waitForPostStart = false;
for (let i = 0; i < message.completions.length; i++) { for (let i = 0; i < completions.length; i++) {
const completion = message.completions[i]; const completion = completions[i];
const execution = message.executions[i];
if (!completion || !execution) continue; if (!completion) continue;
this.#backgroundWorker.taskRunCompletedNotification(completion, execution); this.#backgroundWorker.taskRunCompletedNotification(completion);
} }
}, },
RESUME_AFTER_DURATION: async (message) => { RESUME_AFTER_DURATION: async (message) => {
@@ -420,14 +490,75 @@ class ProdWorker {
this.#prepareForRetry(willCheckpointAndRestore, shouldExit); this.#prepareForRetry(willCheckpointAndRestore, shouldExit);
}, },
REQUEST_ATTEMPT_CANCELLATION: async (message) => { EXECUTE_TASK_RUN_LAZY_ATTEMPT: async (message) => {
if (!this.executing) { if (this.executing) {
logger.error("dropping execute request, already executing");
return; return;
} }
this.executing = true;
try {
const { completion, execution } =
await this.#backgroundWorker.executeTaskRunLazyAttempt(message.lazyPayload);
logger.log("completed", completion);
this.completed.add(execution.attempt.id);
const { willCheckpointAndRestore, shouldExit } =
await this.#coordinatorSocket.socket.emitWithAck("TASK_RUN_COMPLETED", {
version: "v1",
execution,
completion,
});
logger.log("completion acknowledged", { willCheckpointAndRestore, shouldExit });
this.#prepareForRetry(willCheckpointAndRestore, shouldExit);
} catch (error) {
const completion: TaskRunFailedExecutionResult = {
ok: false,
id: message.lazyPayload.runId,
retry: undefined,
error:
error instanceof Error
? {
type: "BUILT_IN_ERROR",
name: error.name,
message: error.message,
stackTrace: error.stack ?? "",
}
: {
type: "BUILT_IN_ERROR",
name: "UnknownError",
message: String(error),
stackTrace: "",
},
};
this.#coordinatorSocket.socket.emit("TASK_RUN_FAILED_TO_RUN", {
version: "v1",
completion,
});
}
},
REQUEST_ATTEMPT_CANCELLATION: async (message) => {
if (!this.executing) {
logger.log("dropping cancel request, not executing", { status: this.#status });
return;
}
logger.log("cancelling attempt", { attemptId: message.attemptId, status: this.#status });
await this.#backgroundWorker.cancelAttempt(message.attemptId); await this.#backgroundWorker.cancelAttempt(message.attemptId);
}, },
REQUEST_EXIT: async () => { REQUEST_EXIT: async (message) => {
if (message.version === "v2" && message.delayInMs) {
logger.log("exit requested with delay", { delayInMs: message.delayInMs });
await setTimeout(message.delayInMs);
}
this.#coordinatorSocket.close(); this.#coordinatorSocket.close();
process.exit(0); process.exit(0);
}, },
@@ -436,7 +567,7 @@ class ProdWorker {
return; return;
} }
this.#coordinatorSocket.socket.emit("READY_FOR_EXECUTION", { this.#coordinatorSocket.socket.emit("READY_FOR_LAZY_ATTEMPT", {
version: "v1", version: "v1",
runId: this.runId, runId: this.runId,
totalCompletions: this.completed.size, totalCompletions: this.completed.size,
@@ -444,6 +575,8 @@ class ProdWorker {
}, },
}, },
onConnection: async (socket, handler, sender, logger) => { onConnection: async (socket, handler, sender, logger) => {
logger.log("connected to coordinator", { status: this.#status });
if (this.waitForPostStart) { if (this.waitForPostStart) {
logger.log("skip connection handler, waiting for post start hook"); logger.log("skip connection handler, waiting for post start hook");
return; return;
@@ -451,11 +584,24 @@ class ProdWorker {
if (this.paused) { if (this.paused) {
if (!this.nextResumeAfter) { if (!this.nextResumeAfter) {
logger.error("Missing next resume reason", { status: this.#status });
this.#emitUnrecoverableError(
"NoNextResume",
"Next resume reason not set while resuming from paused state"
);
return; return;
} }
if (!this.attemptFriendlyId) { if (!this.attemptFriendlyId) {
logger.error("Missing friendly ID"); logger.error("Missing friendly ID", { status: this.#status });
this.#emitUnrecoverableError(
"NoAttemptId",
"Attempt ID not set while resuming from paused state"
);
return; return;
} }
@@ -473,9 +619,10 @@ class ProdWorker {
const taskResources = await this.#initializeWorker(); const taskResources = await this.#initializeWorker();
const { success } = await socket.emitWithAck("INDEX_TASKS", { const { success } = await socket.emitWithAck("INDEX_TASKS", {
version: "v1", version: "v2",
deploymentId: this.deploymentId, deploymentId: this.deploymentId,
...taskResources, ...taskResources,
supportsLazyAttempts: true,
}); });
if (success) { if (success) {
@@ -563,7 +710,7 @@ class ProdWorker {
return; return;
} }
socket.emit("READY_FOR_EXECUTION", { socket.emit("READY_FOR_LAZY_ATTEMPT", {
version: "v1", version: "v1",
runId: this.runId, runId: this.runId,
totalCompletions: this.completed.size, totalCompletions: this.completed.size,
@@ -601,12 +748,7 @@ class ProdWorker {
} }
case "/status": { case "/status": {
return reply.json({ return reply.json(this.#status);
executing: this.executing,
paused: this.paused,
completed: this.completed.size,
nextResumeAfter: this.nextResumeAfter,
});
} }
case "/connect": { case "/connect": {
@@ -768,6 +910,27 @@ class ProdWorker {
return data?.variables ?? {}; return data?.variables ?? {};
} }
get #status() {
return {
executing: this.executing,
paused: this.paused,
completed: this.completed.size,
nextResumeAfter: this.nextResumeAfter,
waitForPostStart: this.waitForPostStart,
attemptFriendlyId: this.attemptFriendlyId,
};
}
#emitUnrecoverableError(name: string, message: string) {
this.#coordinatorSocket.socket.emit("UNRECOVERABLE_ERROR", {
version: "v1",
error: {
name,
message,
},
});
}
start() { start() {
this.#httpServer.listen(this.#httpPort, this.host); this.#httpServer.listen(this.#httpPort, this.host);
} }
@@ -170,8 +170,8 @@ const zodIpc = new ZodIpcConnection({
_isRunning = false; _isRunning = false;
} }
}, },
TASK_RUN_COMPLETED_NOTIFICATION: async ({ completion, execution }) => { TASK_RUN_COMPLETED_NOTIFICATION: async ({ completion }) => {
prodRuntimeManager.resumeTask(completion, execution); prodRuntimeManager.resumeTask(completion);
}, },
WAIT_COMPLETED_NOTIFICATION: async () => { WAIT_COMPLETED_NOTIFICATION: async () => {
prodRuntimeManager.resumeAfterDuration(); prodRuntimeManager.resumeAfterDuration();
@@ -179,23 +179,6 @@ const zodIpc = new ZodIpcConnection({
CLEANUP: async ({ flush, kill }, sender) => { CLEANUP: async ({ flush, kill }, sender) => {
if (kill) { if (kill) {
await tracingSDK.flush(); await tracingSDK.flush();
if (_execution) {
// Fail currently executing attempt
await sender.send("TASK_RUN_COMPLETED", {
execution: _execution,
result: {
ok: false,
id: _execution.run.id,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.GRACEFUL_EXIT_TIMEOUT,
message: "Worker process killed while attempt in progress.",
},
},
});
}
// Now we need to exit the process // Now we need to exit the process
await sender.send("READY_TO_DISPOSE", undefined); await sender.send("READY_TO_DISPOSE", undefined);
} else { } else {
@@ -228,7 +211,7 @@ zodIpc.send("TASKS_READY", { tasks: TASK_METADATA }).catch((err) => {
process.title = "trigger-prod-worker"; process.title = "trigger-prod-worker";
async function asyncHeartbeat(initialDelayInSeconds: number = 30, intervalInSeconds: number = 5) { async function asyncHeartbeat(initialDelayInSeconds: number = 30, intervalInSeconds: number = 20) {
async function _doHeartbeat() { async function _doHeartbeat() {
while (true) { while (true) {
if (_isRunning && _execution) { if (_isRunning && _execution) {
-2
View File
@@ -46,7 +46,6 @@ export interface TaskOperationsCreateOptions {
orgId: string; orgId: string;
projectId: string; projectId: string;
runId: string; runId: string;
attemptId: string;
} }
export interface TaskOperationsRestoreOptions { export interface TaskOperationsRestoreOptions {
@@ -129,7 +128,6 @@ export class ProviderShell implements Provider {
orgId: message.data.orgId, orgId: message.data.orgId,
projectId: message.data.projectId, projectId: message.data.projectId,
runId: message.data.runId, runId: message.data.runId,
attemptId: message.data.id,
}); });
} catch (error) { } catch (error) {
logger.error("create failed", error); logger.error("create failed", error);
+8
View File
@@ -37,6 +37,14 @@
"require": "./dist/v3/otel/index.js", "require": "./dist/v3/otel/index.js",
"types": "./dist/v3/otel/index.d.ts" "types": "./dist/v3/otel/index.d.ts"
}, },
"./v3/zodfetch": {
"import": {
"types": "./dist/v3/zodfetch.d.mts",
"default": "./dist/v3/zodfetch.mjs"
},
"require": "./dist/v3/zodfetch.js",
"types": "./dist/v3/zodfetch.d.ts"
},
"./v3/zodMessageHandler": { "./v3/zodMessageHandler": {
"import": { "import": {
"types": "./dist/v3/zodMessageHandler.d.mts", "types": "./dist/v3/zodMessageHandler.d.mts",
+8 -4
View File
@@ -84,13 +84,13 @@ export function createJsonErrorObject(error: TaskRunError) {
export function correctErrorStackTrace( export function correctErrorStackTrace(
stackTrace: string, stackTrace: string,
projectDir?: string, projectDir?: string,
options?: { removeFirstLine?: boolean } options?: { removeFirstLine?: boolean; isDev?: boolean }
) { ) {
const [errorLine, ...traceLines] = stackTrace.split("\n"); const [errorLine, ...traceLines] = stackTrace.split("\n");
return [ return [
options?.removeFirstLine ? undefined : errorLine, options?.removeFirstLine ? undefined : errorLine,
...traceLines.map((line) => correctStackTraceLine(line, projectDir)), ...traceLines.map((line) => correctStackTraceLine(line, projectDir, options?.isDev)),
] ]
.filter(Boolean) .filter(Boolean)
.join("\n"); .join("\n");
@@ -102,17 +102,21 @@ const LINES_TO_IGNORE = [
/TaskExecutor/, /TaskExecutor/,
/EXECUTE_TASK_RUN/, /EXECUTE_TASK_RUN/,
/@trigger.dev\/core/, /@trigger.dev\/core/,
/packages\/core\/src\/v3/,
/safeJsonProcess/, /safeJsonProcess/,
/__entryPoint.ts/, /__entryPoint.ts/,
/ZodIpc/,
/startActiveSpan/,
/processTicksAndRejections/,
]; ];
function correctStackTraceLine(line: string, projectDir?: string) { function correctStackTraceLine(line: string, projectDir?: string, isDev?: boolean) {
if (LINES_TO_IGNORE.some((regex) => regex.test(line))) { if (LINES_TO_IGNORE.some((regex) => regex.test(line))) {
return; return;
} }
// Check to see if the path is inside the project directory // Check to see if the path is inside the project directory
if (projectDir && !line.includes(projectDir)) { if (isDev && projectDir && !line.includes(projectDir)) {
return; return;
} }
@@ -80,18 +80,18 @@ export class DevRuntimeManager implements RuntimeManager {
}; };
} }
resumeTask(completion: TaskRunExecutionResult, execution: TaskRunExecution): void { resumeTask(completion: TaskRunExecutionResult, runId: string): void {
const wait = this._taskWaits.get(execution.run.id); const wait = this._taskWaits.get(runId);
if (!wait) { if (!wait) {
// We need to store the completion in case the task is awaited later // We need to store the completion in case the task is awaited later
this._pendingCompletionNotifications.set(execution.run.id, completion); this._pendingCompletionNotifications.set(runId, completion);
return; return;
} }
wait.resolve(completion); wait.resolve(completion);
this._taskWaits.delete(execution.run.id); this._taskWaits.delete(runId);
} }
} }
@@ -55,10 +55,14 @@ export class ProdRuntimeManager implements RuntimeManager {
this._waitForDuration = { resolve, reject }; this._waitForDuration = { resolve, reject };
}); });
const { willCheckpointAndRestore } = await this.ipc.sendWithAck("WAIT_FOR_DURATION", { const { willCheckpointAndRestore } = await this.ipc.sendWithAck(
ms, "WAIT_FOR_DURATION",
now, {
}); ms,
now,
},
31_000
);
if (!willCheckpointAndRestore) { if (!willCheckpointAndRestore) {
await internalTimeout; await internalTimeout;
@@ -74,18 +78,24 @@ export class ProdRuntimeManager implements RuntimeManager {
// Resets the clock to the current time // Resets the clock to the current time
clock.reset(); clock.reset();
// The coordinator should cancel any in-progress checkpoints try {
const { checkpointCanceled, version } = await this.ipc.sendWithAck( // The coordinator should cancel any in-progress checkpoints
"CANCEL_CHECKPOINT", const { checkpointCanceled, version } = await this.ipc.sendWithAck(
{ "CANCEL_CHECKPOINT",
version: "v2", {
reason: "WAIT_FOR_DURATION", version: "v2",
}, reason: "WAIT_FOR_DURATION",
31_000 },
); 31_000
);
if (checkpointCanceled) { if (checkpointCanceled) {
// There won't be a checkpoint or external resume and we've already completed our internal timeout // There won't be a checkpoint or external resume and we've already completed our internal timeout
return;
}
} catch (error) {
// If the cancellation times out, we will proceed as if the checkpoint was canceled
logger.debug("Checkpoint cancellation timed out", { error });
return; return;
} }
@@ -98,19 +108,9 @@ export class ProdRuntimeManager implements RuntimeManager {
return; return;
} }
process.stdout.write("pre");
process.stdout.write(JSON.stringify(clock.preciseNow()));
console.log("pre", clock.preciseNow());
// Resets the clock to the current time // Resets the clock to the current time
clock.reset(); clock.reset();
console.log("post", clock.preciseNow());
process.stdout.write("post");
process.stdout.write(JSON.stringify(clock.preciseNow()));
this._waitForDuration.resolve("external"); this._waitForDuration.resolve("external");
this._waitForDuration = undefined; this._waitForDuration = undefined;
} }
@@ -167,8 +167,8 @@ export class ProdRuntimeManager implements RuntimeManager {
}; };
} }
resumeTask(completion: TaskRunExecutionResult, execution: TaskRunExecution): void { resumeTask(completion: TaskRunExecutionResult): void {
const wait = this._taskWaits.get(execution.run.id); const wait = this._taskWaits.get(completion.id);
if (!wait) { if (!wait) {
return; return;
@@ -176,7 +176,7 @@ export class ProdRuntimeManager implements RuntimeManager {
wait.resolve(completion); wait.resolve(completion);
this._taskWaits.delete(execution.run.id); this._taskWaits.delete(completion.id);
} }
private get waitThresholdInMs(): number { private get waitThresholdInMs(): number {
+1
View File
@@ -41,6 +41,7 @@ export type GetProjectEnvResponse = z.infer<typeof GetProjectEnvResponse>;
export const CreateBackgroundWorkerRequestBody = z.object({ export const CreateBackgroundWorkerRequestBody = z.object({
localOnly: z.boolean(), localOnly: z.boolean(),
metadata: BackgroundWorkerMetadata, metadata: BackgroundWorkerMetadata,
supportsLazyAttempts: z.boolean().optional(),
}); });
export type CreateBackgroundWorkerRequestBody = z.infer<typeof CreateBackgroundWorkerRequestBody>; export type CreateBackgroundWorkerRequestBody = z.infer<typeof CreateBackgroundWorkerRequestBody>;
+3
View File
@@ -31,6 +31,7 @@ export const TaskRunErrorCodes = {
TASK_EXECUTION_FAILED: "TASK_EXECUTION_FAILED", TASK_EXECUTION_FAILED: "TASK_EXECUTION_FAILED",
TASK_EXECUTION_ABORTED: "TASK_EXECUTION_ABORTED", TASK_EXECUTION_ABORTED: "TASK_EXECUTION_ABORTED",
TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE: "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE", TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE: "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE",
TASK_PROCESS_SIGKILL_TIMEOUT: "TASK_PROCESS_SIGKILL_TIMEOUT",
TASK_RUN_CANCELLED: "TASK_RUN_CANCELLED", TASK_RUN_CANCELLED: "TASK_RUN_CANCELLED",
TASK_OUTPUT_ERROR: "TASK_OUTPUT_ERROR", TASK_OUTPUT_ERROR: "TASK_OUTPUT_ERROR",
HANDLE_ERROR_ERROR: "HANDLE_ERROR_ERROR", HANDLE_ERROR_ERROR: "HANDLE_ERROR_ERROR",
@@ -47,10 +48,12 @@ export const TaskRunInternalError = z.object({
"TASK_EXECUTION_FAILED", "TASK_EXECUTION_FAILED",
"TASK_EXECUTION_ABORTED", "TASK_EXECUTION_ABORTED",
"TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE", "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE",
"TASK_PROCESS_SIGKILL_TIMEOUT",
"TASK_RUN_CANCELLED", "TASK_RUN_CANCELLED",
"TASK_OUTPUT_ERROR", "TASK_OUTPUT_ERROR",
"HANDLE_ERROR_ERROR", "HANDLE_ERROR_ERROR",
"GRACEFUL_EXIT_TIMEOUT", "GRACEFUL_EXIT_TIMEOUT",
"TASK_RUN_HEARTBEAT_TIMEOUT",
]), ]),
message: z.string().optional(), message: z.string().optional(),
}); });
+213 -32
View File
@@ -1,11 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import { TaskRunExecution, TaskRunExecutionResult } from "./common"; import { TaskRunExecution, TaskRunExecutionResult, TaskRunFailedExecutionResult } from "./common";
import { import {
EnvironmentType, EnvironmentType,
Machine, Machine,
ProdTaskRunExecution, ProdTaskRunExecution,
ProdTaskRunExecutionPayload, ProdTaskRunExecutionPayload,
TaskMetadataWithFilePath, TaskMetadataWithFilePath,
TaskRunExecutionLazyAttemptPayload,
TaskRunExecutionPayload, TaskRunExecutionPayload,
WaitReason, WaitReason,
} from "./schemas"; } from "./schemas";
@@ -27,13 +29,17 @@ export const BackgroundWorkerServerMessages = z.discriminatedUnion("type", [
version: z.string(), version: z.string(),
machine: Machine, machine: Machine,
// identifiers // identifiers
id: z.string(), // attempt id: z.string().optional(), // TODO: Remove this completely in a future release
envId: z.string(), envId: z.string(),
envType: EnvironmentType, envType: EnvironmentType,
orgId: z.string(), orgId: z.string(),
projectId: z.string(), projectId: z.string(),
runId: z.string(), runId: z.string(),
}), }),
z.object({
type: z.literal("EXECUTE_RUN_LAZY_ATTEMPT"),
payload: TaskRunExecutionLazyAttemptPayload,
}),
]); ]);
export type BackgroundWorkerServerMessages = z.infer<typeof BackgroundWorkerServerMessages>; export type BackgroundWorkerServerMessages = z.infer<typeof BackgroundWorkerServerMessages>;
@@ -57,11 +63,21 @@ export const BackgroundWorkerClientMessages = z.discriminatedUnion("type", [
completion: TaskRunExecutionResult, completion: TaskRunExecutionResult,
execution: TaskRunExecution, execution: TaskRunExecution,
}), }),
z.object({
version: z.literal("v1").default("v1"),
type: z.literal("TASK_RUN_FAILED_TO_RUN"),
completion: TaskRunFailedExecutionResult,
}),
z.object({ z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
type: z.literal("TASK_HEARTBEAT"), type: z.literal("TASK_HEARTBEAT"),
id: z.string(), id: z.string(),
}), }),
z.object({
version: z.literal("v1").default("v1"),
type: z.literal("TASK_RUN_HEARTBEAT"),
id: z.string(),
}),
]); ]);
export type BackgroundWorkerClientMessages = z.infer<typeof BackgroundWorkerClientMessages>; export type BackgroundWorkerClientMessages = z.infer<typeof BackgroundWorkerClientMessages>;
@@ -78,6 +94,7 @@ export const clientWebsocketMessages = {
READY_FOR_TASKS: z.object({ READY_FOR_TASKS: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(), backgroundWorkerId: z.string(),
inProgressRuns: z.string().array().optional(),
}), }),
BACKGROUND_WORKER_DEPRECATED: z.object({ BACKGROUND_WORKER_DEPRECATED: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -97,11 +114,17 @@ export const workerToChildMessages = {
traceContext: z.record(z.unknown()), traceContext: z.record(z.unknown()),
metadata: BackgroundWorkerProperties, metadata: BackgroundWorkerProperties,
}), }),
TASK_RUN_COMPLETED_NOTIFICATION: z.object({ TASK_RUN_COMPLETED_NOTIFICATION: z.discriminatedUnion("version", [
version: z.literal("v1").default("v1"), z.object({
completion: TaskRunExecutionResult, version: z.literal("v1"),
execution: TaskRunExecution, completion: TaskRunExecutionResult,
}), execution: TaskRunExecution,
}),
z.object({
version: z.literal("v2"),
completion: TaskRunExecutionResult,
}),
]),
CLEANUP: z.object({ CLEANUP: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
flush: z.boolean().default(false), flush: z.boolean().default(false),
@@ -142,6 +165,10 @@ export const childToWorkerMessages = {
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
id: z.string(), id: z.string(),
}), }),
TASK_RUN_HEARTBEAT: z.object({
version: z.literal("v1").default("v1"),
id: z.string(),
}),
READY_TO_DISPOSE: z.undefined(), READY_TO_DISPOSE: z.undefined(),
WAIT_FOR_DURATION: z.object({ WAIT_FOR_DURATION: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -182,6 +209,12 @@ export const ProdChildToWorkerMessages = {
id: z.string(), id: z.string(),
}), }),
}, },
TASK_RUN_HEARTBEAT: {
message: z.object({
version: z.literal("v1").default("v1"),
id: z.string(),
}),
},
READY_TO_DISPOSE: { READY_TO_DISPOSE: {
message: z.undefined(), message: z.undefined(),
}, },
@@ -247,11 +280,17 @@ export const ProdWorkerToChildMessages = {
}), }),
}, },
TASK_RUN_COMPLETED_NOTIFICATION: { TASK_RUN_COMPLETED_NOTIFICATION: {
message: z.object({ message: z.discriminatedUnion("version", [
version: z.literal("v1").default("v1"), z.object({
completion: TaskRunExecutionResult, version: z.literal("v1"),
execution: TaskRunExecution, completion: TaskRunExecutionResult,
}), execution: TaskRunExecution,
}),
z.object({
version: z.literal("v2"),
completion: TaskRunExecutionResult,
}),
]),
}, },
CLEANUP: { CLEANUP: {
message: z.object({ message: z.object({
@@ -379,6 +418,18 @@ export const PlatformToProviderMessages = {
}, },
}; };
const CreateWorkerMessage = z.object({
projectRef: z.string(),
envId: z.string(),
deploymentId: z.string(),
metadata: z.object({
cliPackageVersion: z.string().optional(),
contentHash: z.string(),
packageVersion: z.string(),
tasks: TaskResource.array(),
}),
});
export const CoordinatorToPlatformMessages = { export const CoordinatorToPlatformMessages = {
LOG: { LOG: {
message: z.object({ message: z.object({
@@ -388,18 +439,15 @@ export const CoordinatorToPlatformMessages = {
}), }),
}, },
CREATE_WORKER: { CREATE_WORKER: {
message: z.object({ message: z.discriminatedUnion("version", [
version: z.literal("v1").default("v1"), CreateWorkerMessage.extend({
projectRef: z.string(), version: z.literal("v1"),
envId: z.string(),
deploymentId: z.string(),
metadata: z.object({
cliPackageVersion: z.string().optional(),
contentHash: z.string(),
packageVersion: z.string(),
tasks: TaskResource.array(),
}), }),
}), CreateWorkerMessage.extend({
version: z.literal("v2"),
supportsLazyAttempts: z.boolean(),
}),
]),
callback: z.discriminatedUnion("success", [ callback: z.discriminatedUnion("success", [
z.object({ z.object({
success: z.literal(false), success: z.literal(false),
@@ -409,6 +457,23 @@ export const CoordinatorToPlatformMessages = {
}), }),
]), ]),
}, },
CREATE_TASK_RUN_ATTEMPT: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
envId: z.string(),
}),
callback: z.discriminatedUnion("success", [
z.object({
success: z.literal(false),
reason: z.string().optional(),
}),
z.object({
success: z.literal(true),
executionPayload: ProdTaskRunExecutionPayload,
}),
]),
},
READY_FOR_EXECUTION: { READY_FOR_EXECUTION: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -425,6 +490,24 @@ export const CoordinatorToPlatformMessages = {
}), }),
]), ]),
}, },
READY_FOR_LAZY_ATTEMPT: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
envId: z.string(),
totalCompletions: z.number(),
}),
callback: z.discriminatedUnion("success", [
z.object({
success: z.literal(false),
reason: z.string().optional(),
}),
z.object({
success: z.literal(true),
lazyPayload: TaskRunExecutionLazyAttemptPayload,
}),
]),
},
READY_FOR_RESUME: { READY_FOR_RESUME: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -445,12 +528,24 @@ export const CoordinatorToPlatformMessages = {
.optional(), .optional(),
}), }),
}, },
TASK_RUN_FAILED_TO_RUN: {
message: z.object({
version: z.literal("v1").default("v1"),
completion: TaskRunFailedExecutionResult,
}),
},
TASK_HEARTBEAT: { TASK_HEARTBEAT: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
attemptFriendlyId: z.string(), attemptFriendlyId: z.string(),
}), }),
}, },
TASK_RUN_HEARTBEAT: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
}),
},
CHECKPOINT_CREATED: { CHECKPOINT_CREATED: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -490,6 +585,17 @@ export const CoordinatorToPlatformMessages = {
}), }),
}), }),
}, },
RUN_CRASHED: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
error: z.object({
name: z.string(),
message: z.string(),
stack: z.string().optional(),
}),
}),
},
}; };
export const PlatformToCoordinatorMessages = { export const PlatformToCoordinatorMessages = {
@@ -517,6 +623,13 @@ export const PlatformToCoordinatorMessages = {
attemptFriendlyId: z.string(), attemptFriendlyId: z.string(),
}), }),
}, },
REQUEST_RUN_CANCELLATION: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
delayInMs: z.number().optional(),
}),
},
READY_FOR_RETRY: { READY_FOR_RETRY: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -563,6 +676,13 @@ export const SharedQueueToClientMessages = {
}, },
}; };
const IndexTasksMessage = z.object({
version: z.literal("v1"),
deploymentId: z.string(),
tasks: TaskResource.array(),
packageVersion: z.string(),
});
export const ProdWorkerToCoordinatorMessages = { export const ProdWorkerToCoordinatorMessages = {
LOG: { LOG: {
message: z.object({ message: z.object({
@@ -572,12 +692,15 @@ export const ProdWorkerToCoordinatorMessages = {
callback: z.void(), callback: z.void(),
}, },
INDEX_TASKS: { INDEX_TASKS: {
message: z.object({ message: z.discriminatedUnion("version", [
version: z.literal("v1").default("v1"), IndexTasksMessage.extend({
deploymentId: z.string(), version: z.literal("v1"),
tasks: TaskResource.array(), }),
packageVersion: z.string(), IndexTasksMessage.extend({
}), version: z.literal("v2"),
supportsLazyAttempts: z.boolean(),
}),
]),
callback: z.discriminatedUnion("success", [ callback: z.discriminatedUnion("success", [
z.object({ z.object({
success: z.literal(false), success: z.literal(false),
@@ -594,6 +717,13 @@ export const ProdWorkerToCoordinatorMessages = {
totalCompletions: z.number(), totalCompletions: z.number(),
}), }),
}, },
READY_FOR_LAZY_ATTEMPT: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
totalCompletions: z.number(),
}),
},
READY_FOR_RESUME: { READY_FOR_RESUME: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -630,6 +760,12 @@ export const ProdWorkerToCoordinatorMessages = {
attemptFriendlyId: z.string(), attemptFriendlyId: z.string(),
}), }),
}, },
TASK_RUN_HEARTBEAT: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
}),
},
TASK_RUN_COMPLETED: { TASK_RUN_COMPLETED: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -641,6 +777,12 @@ export const ProdWorkerToCoordinatorMessages = {
shouldExit: z.boolean(), shouldExit: z.boolean(),
}), }),
}, },
TASK_RUN_FAILED_TO_RUN: {
message: z.object({
version: z.literal("v1").default("v1"),
completion: TaskRunFailedExecutionResult,
}),
},
WAIT_FOR_DURATION: { WAIT_FOR_DURATION: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -686,8 +828,35 @@ export const ProdWorkerToCoordinatorMessages = {
}), }),
}), }),
}, },
CREATE_TASK_RUN_ATTEMPT: {
message: z.object({
version: z.literal("v1").default("v1"),
runId: z.string(),
}),
callback: z.discriminatedUnion("success", [
z.object({
success: z.literal(false),
reason: z.string().optional(),
}),
z.object({
success: z.literal(true),
executionPayload: ProdTaskRunExecutionPayload,
}),
]),
},
UNRECOVERABLE_ERROR: {
message: z.object({
version: z.literal("v1").default("v1"),
error: z.object({
name: z.string(),
message: z.string(),
stack: z.string().optional(),
}),
}),
},
}; };
// TODO: The coordinator can only safely use v1 worker messages, higher versions will need a new flag, e.g. SUPPORTS_VERSIONED_MESSAGES
export const CoordinatorToProdWorkerMessages = { export const CoordinatorToProdWorkerMessages = {
RESUME_AFTER_DEPENDENCY: { RESUME_AFTER_DEPENDENCY: {
message: z.object({ message: z.object({
@@ -709,6 +878,12 @@ export const CoordinatorToProdWorkerMessages = {
executionPayload: ProdTaskRunExecutionPayload, executionPayload: ProdTaskRunExecutionPayload,
}), }),
}, },
EXECUTE_TASK_RUN_LAZY_ATTEMPT: {
message: z.object({
version: z.literal("v1").default("v1"),
lazyPayload: TaskRunExecutionLazyAttemptPayload,
}),
},
REQUEST_ATTEMPT_CANCELLATION: { REQUEST_ATTEMPT_CANCELLATION: {
message: z.object({ message: z.object({
version: z.literal("v1").default("v1"), version: z.literal("v1").default("v1"),
@@ -716,9 +891,15 @@ export const CoordinatorToProdWorkerMessages = {
}), }),
}, },
REQUEST_EXIT: { REQUEST_EXIT: {
message: z.object({ message: z.discriminatedUnion("version", [
version: z.literal("v1").default("v1"), z.object({
}), version: z.literal("v1"),
}),
z.object({
version: z.literal("v2"),
delayInMs: z.number().optional(),
}),
]),
}, },
READY_FOR_RETRY: { READY_FOR_RETRY: {
message: z.object({ message: z.object({
+10
View File
@@ -224,3 +224,13 @@ export type ResolvedConfig = RequireKeys<
export const WaitReason = z.enum(["WAIT_FOR_DURATION", "WAIT_FOR_TASK", "WAIT_FOR_BATCH"]); export const WaitReason = z.enum(["WAIT_FOR_DURATION", "WAIT_FOR_TASK", "WAIT_FOR_BATCH"]);
export type WaitReason = z.infer<typeof WaitReason>; export type WaitReason = z.infer<typeof WaitReason>;
export const TaskRunExecutionLazyAttemptPayload = z.object({
runId: z.string(),
messageId: z.string(),
isTest: z.boolean(),
traceContext: z.record(z.unknown()),
environment: z.record(z.string()).optional(),
});
export type TaskRunExecutionLazyAttemptPayload = z.infer<typeof TaskRunExecutionLazyAttemptPayload>;
+26 -5
View File
@@ -1,8 +1,9 @@
import type { Socket } from "socket.io-client"; import type { Socket } from "socket.io-client";
import { io } from "socket.io-client"; import { io } from "socket.io-client";
import { z } from "zod"; import { ZodError, z } from "zod";
import { EventEmitterLike, ZodMessageValueSchema } from "./zodMessageHandler"; import { EventEmitterLike, ZodMessageValueSchema } from "./zodMessageHandler";
import { LogLevel, SimpleStructuredLogger, StructuredLogger } from "./utils/structuredLogger"; import { LogLevel, SimpleStructuredLogger, StructuredLogger } from "./utils/structuredLogger";
import { fromZodError } from "zod-validation-error";
export interface ZodSocketMessageCatalogSchema { export interface ZodSocketMessageCatalogSchema {
[key: string]: [key: string]:
@@ -81,7 +82,7 @@ export type MessagesFromSocketCatalog<TMessageCatalog extends ZodSocketMessageCa
}[keyof TMessageCatalog]; }[keyof TMessageCatalog];
const messageSchema = z.object({ const messageSchema = z.object({
version: z.literal("v1").default("v1"), version: z.string(),
type: z.string(), type: z.string(),
payload: z.unknown(), payload: z.unknown(),
}); });
@@ -127,10 +128,22 @@ export class ZodSocketMessageHandler<TRPCCatalog extends ZodSocketMessageCatalog
throw new Error(`Unknown message type: ${parsedMessage.data.type}`); throw new Error(`Unknown message type: ${parsedMessage.data.type}`);
} }
const parsedPayload = schema.safeParse(parsedMessage.data.payload); const messageWithVersion = {
version: parsedMessage.data.version,
...(typeof parsedMessage.data.payload === "object" ? parsedMessage.data.payload : {}),
};
const parsedPayload = schema.safeParse(messageWithVersion);
if (!parsedPayload.success) { if (!parsedPayload.success) {
throw new Error(`Failed to parse message payload: ${JSON.stringify(parsedPayload.error)}`); console.error("Failed to parse message payload", {
message,
payload: messageWithVersion,
});
throw parsedPayload.error instanceof ZodError
? fromZodError(parsedPayload.error)
: parsedPayload.error;
} }
return { return {
@@ -166,7 +179,15 @@ export class ZodSocketMessageHandler<TRPCCatalog extends ZodSocketMessageCatalog
ack = await this.handleMessage({ type: eventName, version, payload }); ack = await this.handleMessage({ type: eventName, version, payload });
} }
} catch (error) { } catch (error) {
log.error("Error while handling message", { error }); log.error("Error while handling message", {
error:
error instanceof Error
? {
message: error.message,
stack: error.stack,
}
: error,
});
return; return;
} }
+1
View File
@@ -15,6 +15,7 @@ export default defineConfig({
"./src/v3/dev/index.ts", "./src/v3/dev/index.ts",
"./src/v3/prod/index.ts", "./src/v3/prod/index.ts",
"./src/v3/workers/index.ts", "./src/v3/workers/index.ts",
"./src/v3/zodfetch.ts",
], ],
external: ["node:stream"], external: ["node:stream"],
}); });
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BackgroundWorker" ADD COLUMN "supportsLazyAttempts" BOOLEAN NOT NULL DEFAULT false;
+2
View File
@@ -1563,6 +1563,8 @@ model BackgroundWorker {
deployment WorkerDeployment? deployment WorkerDeployment?
supportsLazyAttempts Boolean @default(false)
@@unique([projectId, runtimeEnvironmentId, version]) @@unique([projectId, runtimeEnvironmentId, version])
} }
@@ -0,0 +1,394 @@
import { logger, task, wait } from "@trigger.dev/sdk/v3";
export const lazyImmediate = task({
id: "lazy-immediate",
run: async (payload: { forceError?: boolean }) => {
logger.info("Log something", { payload });
logger.info("Log something else", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyWait = task({
id: "lazy-wait",
run: async (payload: { forceError?: boolean; delayInSeconds?: number }) => {
logger.info("Log something", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazySingleDependency = task({
id: "lazy-single-dependency",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const result = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result", { result });
logger.info("Log something else", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyBatchDependency = task({
id: "lazy-batch-dependency",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const results = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results", { results });
logger.info("Log something else", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyConsecutiveWaits = task({
id: "lazy-consecutive-waits",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyConsecutiveDependencies = task({
id: "lazy-consecutive-dependencies",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const result = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result #1", { result });
logger.info("Log something else", { payload });
const result2 = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result #2", { result2 });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyConsecutiveBatchDependencies = task({
id: "lazy-consecutive-batch-dependencies",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const results = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results #1", { results });
logger.info("Log something else", { payload });
const results2 = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results #2", { results2 });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyWaitThenSingleDependency = task({
id: "lazy-wait-then-single-dependency",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else", { payload });
const result = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result", { result });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyWaitThenBatchDependency = task({
id: "lazy-wait-then-batch-dependency",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else", { payload });
const results = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results", { results });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazySingleDependencyThenWait = task({
id: "lazy-single-dependency-then-wait",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const result = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result", { result });
logger.info("Log something else", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazySingleDependencyThenBatch = task({
id: "lazy-single-dependency-then-batch",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const result = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result", { result });
logger.info("Log something else", { payload });
const results = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results", { results });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyBatchDependencyThenWait = task({
id: "lazy-batch-dependency-then-wait",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const results = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results", { results });
logger.info("Log something else", { payload });
await wait.for({ seconds: payload.delayInSeconds ?? 1 });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
export const lazyBatchDependencyThenSingle = task({
id: "lazy-batch-dependency-then-single",
run: async (payload: {
forceError?: boolean;
forceChildError?: boolean;
delayInSeconds?: number;
}) => {
logger.info("Log something", { payload });
const results = await lazyWait.batchTriggerAndWait([
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
{ payload: { delayInSeconds: payload.delayInSeconds, forceError: payload.forceChildError } },
]);
logger.info("Batch results", { results });
logger.info("Log something else", { payload });
const result = await lazyWait.triggerAndWait({
delayInSeconds: payload.delayInSeconds,
forceError: payload.forceChildError,
});
logger.info("Single result", { result });
logger.info("Log something else again", { payload });
if (payload.forceError) {
throw new Error("Forced error");
}
return {
message: "This is a message",
payload,
};
},
});
@@ -3,7 +3,7 @@ import { logger, task } from "@trigger.dev/sdk/v3";
export const longRunning = task({ export const longRunning = task({
id: "long-running", id: "long-running",
run: async (payload: { message: string }) => { run: async (payload: { message: string }) => {
logger.info("Long running payloadd", { payload }); logger.info("Long running payloadddd", { payload });
// Wait for 3 minutes // Wait for 3 minutes
await new Promise((resolve) => setTimeout(resolve, 3 * 60 * 1000)); await new Promise((resolve) => setTimeout(resolve, 3 * 60 * 1000));
@@ -19,7 +19,22 @@ export const longRunningParent = task({
run: async (payload: { message: string }) => { run: async (payload: { message: string }) => {
logger.info("Long running parent", { payload }); logger.info("Long running parent", { payload });
await longRunning.triggerAndWait({ message: "child" }); const result = await longRunning.triggerAndWait({ message: "child" });
return {
finished: new Date().toISOString(),
result,
};
},
});
export const longRunningWithDotInName = task({
id: "long.running.with.dot",
run: async (payload: { message: string }) => {
logger.info("Long running payloadd", { payload });
// Wait for 3 minutes
await new Promise((resolve) => setTimeout(resolve, 3 * 60 * 1000));
return { return {
finished: new Date().toISOString(), finished: new Date().toISOString(),
+1 -1
View File
@@ -37,7 +37,7 @@ export const config: TriggerConfig = {
retries: { retries: {
enabledInDev: true, enabledInDev: true,
default: { default: {
maxAttempts: 3, maxAttempts: 4,
minTimeoutInMs: 1000, minTimeoutInMs: 1000,
maxTimeoutInMs: 10000, maxTimeoutInMs: 10000,
factor: 2, factor: 2,