v3: task run status and canceling runs (#941)

* WIP task run status, revamped resuming task dependencies

* Don’t select a span when toggling collapsing

* Cancel runs and attempts, in prod and dev
This commit is contained in:
Eric Allam
2024-03-13 15:51:43 +00:00
committed by GitHub
parent fe9435f63f
commit 478ce006cb
42 changed files with 1327 additions and 366 deletions
+1 -2
View File
@@ -4,7 +4,6 @@
"version": "0.0.1",
"description": "",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
"build": "npm run build:bundle",
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.mjs --platform=node --format=esm --target=esnext --banner:js=\"const require = createRequire(import.meta.url);\"",
@@ -31,4 +30,4 @@
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
}
+20
View File
@@ -301,6 +301,16 @@ class TaskCoordinator {
taskSocket.emit("RESUME_AFTER_DURATION", message);
},
REQUEST_ATTEMPT_CANCELLATION: async (message) => {
const taskSocket = await this.#getAttemptSocket(message.attemptId);
if (!taskSocket) {
logger.log("Socket for attempt not found", { attemptId: message.attemptId });
return;
}
taskSocket.emit("REQUEST_ATTEMPT_CANCELLATION", message);
},
},
});
@@ -385,11 +395,21 @@ class TaskCoordinator {
if (!executionAck) {
logger.error("no execution ack", { attemptId: socket.data.attemptId });
socket.emit("REQUEST_EXIT", {
version: "v1",
});
return;
}
if (!executionAck.success) {
logger.error("execution unsuccessful", { attemptId: socket.data.attemptId });
socket.emit("REQUEST_EXIT", {
version: "v1",
});
return;
}
+4 -4
View File
@@ -9,10 +9,10 @@
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/core-apps": ["../core-apps/src"],
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
"@trigger.dev/core/v3": ["../../packages/core/src/v3"],
"@trigger.dev/core/v3/*": ["../../packages/core/src/v3/*"],
"@trigger.dev/core-apps": ["../../packages/core-apps/src"],
"@trigger.dev/core-apps/*": ["../../packages/core-apps/src/*"]
}
}
}
+1 -2
View File
@@ -4,7 +4,6 @@
"version": "0.0.1",
"description": "",
"main": "dist/index.cjs",
"type": "module",
"scripts": {
"build": "npm run build:bundle",
"build:bundle": "esbuild src/index.ts --bundle --outfile=dist/index.mjs --platform=node --format=esm --target=esnext --banner:js=\"const require = createRequire(import.meta.url);\"",
@@ -29,4 +28,4 @@
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}
}
+4 -4
View File
@@ -7,10 +7,10 @@
"strict": true,
"skipLibCheck": true,
"paths": {
"@trigger.dev/core/v3": ["../core/src/v3"],
"@trigger.dev/core/v3/*": ["../core/src/v3/*"],
"@trigger.dev/core-apps": ["../core-apps/src"],
"@trigger.dev/core-apps/*": ["../core-apps/src/*"]
"@trigger.dev/core/v3": ["../../packages/core/src/v3"],
"@trigger.dev/core/v3/*": ["../../packages/core/src/v3/*"],
"@trigger.dev/core-apps": ["../../packages/core-apps/src"],
"@trigger.dev/core-apps/*": ["../../packages/core-apps/src/*"]
}
}
}
@@ -334,7 +334,6 @@ function TasksTreeView({
onClick={(e) => {
e.stopPropagation();
toggleExpandNode(node.id);
selectNode(node.id);
scrollToNode(node.id);
}}
>
@@ -52,7 +52,7 @@ export default function Page() {
<div className={cn("grid h-full max-h-full grid-cols-1")}>
<ResizablePanelGroup direction="horizontal" className="h-full max-h-full">
<ResizablePanel order={1} minSize={20} defaultSize={30}>
<div className="flex flex-col px-3">
<div className="flex h-full max-h-full flex-col overflow-hidden px-3">
{tasks.length === 0 ? (
<NoTaskInstructions />
) : (
@@ -81,7 +81,7 @@ function TaskSelector({ tasks }: { tasks: TaskListItem[] }) {
const project = useProject();
return (
<div className="flex flex-col divide-y divide-charcoal-800">
<div className="flex flex-col divide-y divide-charcoal-800 overflow-y-auto">
{tasks.map((t) => (
<NavLink key={t.id} to={v3TestTaskPath(organization, project, t)}>
{({ isActive, isPending }) => (
@@ -0,0 +1,52 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { json } from "@remix-run/server-runtime";
import { z } from "zod";
import { prisma } from "~/db.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { CancelTaskRunService } from "~/v3/services/cancelTaskRun.server";
const ParamsSchema = z.object({
runParam: z.string(),
});
export async function action({ request, params }: ActionFunctionArgs) {
// Ensure this is a POST request
if (request.method.toUpperCase() !== "POST") {
return { status: 405, body: "Method Not Allowed" };
}
// 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 runId" }, { status: 400 });
}
const { runParam } = parsed.data;
const taskRun = await prisma.taskRun.findUnique({
where: {
friendlyId: runParam,
},
});
if (!taskRun) {
return json({ error: "Run not found" }, { status: 404 });
}
const service = new CancelTaskRunService();
try {
await service.call(taskRun);
} catch (error) {
return json({ error: "Internal Server Error" }, { status: 500 });
}
return json({ message: "Run cancelled" }, { status: 200 });
}
+41
View File
@@ -30,6 +30,9 @@ import { DeliverWebhookRequestService } from "./sources/deliverWebhookRequest.se
import { PerformTaskOperationService } from "./tasks/performTaskOperation.server";
import { ProcessCallbackTimeoutService } from "./tasks/processCallbackTimeout.server";
import { ResumeTaskService } from "./tasks/resumeTask.server";
import { ResumeTaskRunDependenciesService } from "~/v3/services/resumeTaskRunDependencies.server";
import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
import { ResumeTaskDependencyService } from "~/v3/services/resumeTaskDependency.server";
const workerCatalog = {
indexEndpoint: z.object({
@@ -107,6 +110,17 @@ const workerCatalog = {
"v3.indexDeployment": z.object({
id: z.string(),
}),
"v3.resumeTaskRunDependencies": z.object({
attemptId: z.string(),
}),
"v3.resumeBatchRun": z.object({
batchRunId: z.string(),
sourceTaskAttemptId: z.string(),
}),
"v3.resumeTaskDependency": z.object({
dependencyId: z.string(),
sourceTaskAttemptId: z.string(),
}),
};
const executionWorkerCatalog = {
@@ -443,6 +457,33 @@ function getWorkerQueue() {
return await service.call(payload.id);
},
},
"v3.resumeTaskRunDependencies": {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new ResumeTaskRunDependenciesService();
return await service.call(payload.attemptId);
},
},
"v3.resumeBatchRun": {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new ResumeBatchRunService();
return await service.call(payload.batchRunId, payload.sourceTaskAttemptId);
},
},
"v3.resumeTaskDependency": {
priority: 0,
maxAttempts: 5,
handler: async (payload, job) => {
const service = new ResumeTaskDependencyService();
return await service.call(payload.dependencyId, payload.sourceTaskAttemptId);
},
},
},
});
}
@@ -10,6 +10,7 @@ import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { DevQueueConsumer } from "./marqs/devQueueConsumer.server";
import type { WebSocket, MessageEvent, CloseEvent, ErrorEvent } from "ws";
import { env } from "~/env.server";
export class AuthenticatedSocketConnection {
public id: string;
@@ -26,6 +27,10 @@ export class AuthenticatedSocketConnection {
schema: serverWebsocketMessages,
sender: async (message) => {
return new Promise((resolve, reject) => {
if (!ws.OPEN) {
return reject(new Error("Websocket is not open"));
}
ws.send(JSON.stringify(message), {}, (err) => {
if (err) {
reject(err);
@@ -84,6 +89,8 @@ export class AuthenticatedSocketConnection {
}
async #handleClose(ev: CloseEvent) {
logger.debug("[AuthenticatedSocketConnection] Websocket closed", { ev });
await this._consumer.stop();
this.onClose.post(ev);
@@ -44,6 +44,7 @@ export type TraceAttributes = Partial<
CreatableEvent,
| "attemptId"
| "isError"
| "isCancelled"
| "runId"
| "runIsTest"
| "output"
@@ -0,0 +1,38 @@
import { z } from "zod";
import { singleton } from "~/utils/singleton";
import { ZodPubSub, ZodSubscriber } from "../utils/zodPubSub.server";
import { env } from "~/env.server";
const messageCatalog = {
CANCEL_ATTEMPT: z.object({
version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(),
attemptId: z.string(),
taskRunId: z.string(),
}),
};
export type DevSubscriber = ZodSubscriber<typeof messageCatalog>;
export const devPubSub = singleton("devPubSub", initializeDevPubSub);
function initializeDevPubSub() {
return new ZodPubSub({
redis: {
port: env.REDIS_PORT,
host: env.REDIS_HOST,
username: env.REDIS_USERNAME,
password: env.REDIS_PASSWORD,
enableAutoPipelining: true,
...(env.REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
},
schema: {
CANCEL_ATTEMPT: z.object({
version: z.literal("v1").default("v1"),
backgroundWorkerId: z.string(),
attemptId: z.string(),
taskRunId: z.string(),
}),
},
});
}
@@ -17,6 +17,7 @@ import { marqs } from "../marqs.server";
import { CancelAttemptService } from "../services/cancelAttempt.server";
import { CompleteAttemptService } from "../services/completeAttempt.server";
import { attributesFromAuthenticatedEnv } from "../tracer.server";
import { DevSubscriber, devPubSub } from "./devPubSub.server";
const tracer = trace.getTracer("devQueueConsumer");
@@ -36,9 +37,11 @@ export type DevQueueConsumerOptions = {
export class DevQueueConsumer {
private _backgroundWorkers: Map<string, BackgroundWorkerWithTasks> = new Map();
private _backgroundWorkerSubscriber: Map<string, DevSubscriber> = new Map();
private _deprecatedWorkers: Map<string, BackgroundWorkerWithTasks> = new Map();
private _enabled = false;
private _options: Required<DevQueueConsumerOptions>;
private _maximumItemsPerTrace: number;
private _traceTimeoutSeconds: number;
private _perTraceCountdown: number | undefined;
private _lastNewTrace: Date | undefined;
private _currentSpanContext: Context | undefined;
@@ -51,12 +54,10 @@ export class DevQueueConsumer {
constructor(
public env: AuthenticatedEnvironment,
private _sender: ZodMessageSender<typeof serverWebsocketMessages>,
options: DevQueueConsumerOptions = {}
private _options: DevQueueConsumerOptions = {}
) {
this._options = {
maximumItemsPerTrace: options.maximumItemsPerTrace ?? 1_000, // 1k items per trace
traceTimeoutSeconds: options.traceTimeoutSeconds ?? 60, // 60 seconds
};
this._traceTimeoutSeconds = _options.traceTimeoutSeconds ?? 60;
this._maximumItemsPerTrace = _options.maximumItemsPerTrace ?? 1_000;
}
// This method is called when a background worker is deprecated and will no longer be used unless a run is locked to it
@@ -87,6 +88,21 @@ export class DevQueueConsumer {
logger.debug("Registered background worker", { backgroundWorker: backgroundWorker.id });
const subscriber = await devPubSub.subscribe(`backgroundWorker:${backgroundWorker.id}:*`);
subscriber.on("CANCEL_ATTEMPT", async (message) => {
await this._sender.send("BACKGROUND_WORKER_MESSAGE", {
backgroundWorkerId: backgroundWorker.friendlyId,
data: {
type: "CANCEL_ATTEMPT",
taskAttemptId: message.attemptId,
taskRunId: message.taskRunId,
},
});
});
this._backgroundWorkerSubscriber.set(backgroundWorker.id, subscriber);
// Start reading from the queue if we haven't already
this.#enable();
}
@@ -133,6 +149,16 @@ export class DevQueueConsumer {
// We need to cancel all the in progress task run attempts and ack the messages so they will stop processing
await this.#cancelInProgressAttempts(reason);
// We need to unsubscribe from the background worker channels
for (const [id, subscriber] of this._backgroundWorkerSubscriber) {
logger.debug("Unsubscribing from background worker channel", { id });
await subscriber.stopListening();
this._backgroundWorkerSubscriber.delete(id);
logger.debug("Unsubscribed from background worker channel", { id });
}
}
async #cancelInProgressAttempts(reason: string) {
@@ -144,6 +170,10 @@ export class DevQueueConsumer {
this._inProgressAttempts.clear();
logger.debug("Cancelling in progress attempts", {
attempts: Array.from(inProgressAttempts.keys()),
});
for (const [attemptId, messageId] of inProgressAttempts) {
await this.#cancelInProgressAttempt(attemptId, messageId, service, cancelledAt, reason);
}
@@ -156,6 +186,8 @@ export class DevQueueConsumer {
cancelledAt: Date,
reason: string
) {
logger.debug("Cancelling in progress attempt", { attemptId, messageId });
try {
await cancelAttemptService.call(attemptId, messageId, cancelledAt, reason, this.env);
} catch (e) {
@@ -189,7 +221,7 @@ export class DevQueueConsumer {
// Check if the trace has expired
if (
this._perTraceCountdown === 0 ||
Date.now() - this._lastNewTrace!.getTime() > this._options.traceTimeoutSeconds * 1000 ||
Date.now() - this._lastNewTrace!.getTime() > this._traceTimeoutSeconds * 1000 ||
this._currentSpanContext === undefined ||
this._endSpanInNextIteration
) {
@@ -309,6 +341,7 @@ export class DevQueueConsumer {
data: {
lockedAt: new Date(),
lockedById: backgroundTask.id,
status: "EXECUTING",
},
include: {
attempts: {
@@ -365,6 +398,7 @@ export class DevQueueConsumer {
backgroundWorkerTaskId: backgroundTask.id,
status: "EXECUTING" as const,
queueId: queue.id,
runtimeEnvironmentId: this.env.id,
},
});
@@ -441,6 +475,11 @@ export class DevQueueConsumer {
},
});
logger.debug("Saving the in progress attempt", {
taskRunAttempt: taskRunAttempt.id,
messageId: message.messageId,
});
this._inProgressAttempts.set(taskRunAttempt.friendlyId, message.messageId);
} catch (e) {
if (e instanceof Error) {
@@ -31,9 +31,11 @@ const MessageBody = z.discriminatedUnion("type", [
z.object({
type: z.literal("RESUME"),
completedAttemptIds: z.string().array(),
resumableAttemptId: z.string(),
}),
z.object({
type: z.literal("RESUME_AFTER_DURATION"),
resumableAttemptId: z.string(),
}),
]);
@@ -42,6 +44,8 @@ type BackgroundWorkerWithTasks = BackgroundWorker & { tasks: BackgroundWorkerTas
export type SharedQueueConsumerOptions = {
maximumItemsPerTrace?: number;
traceTimeoutSeconds?: number;
nextTickInterval?: number;
interval?: number;
};
export class SharedQueueConsumer {
@@ -66,6 +70,8 @@ export class SharedQueueConsumer {
this._options = {
maximumItemsPerTrace: options.maximumItemsPerTrace ?? 1_000, // 1k items per trace
traceTimeoutSeconds: options.traceTimeoutSeconds ?? 60, // 60 seconds
nextTickInterval: options.nextTickInterval ?? 1000, // 1 second
interval: options.interval ?? 100, // 100ms
};
}
@@ -233,7 +239,7 @@ export class SharedQueueConsumer {
const message = await marqs?.dequeueMessageInSharedQueue();
if (!message) {
setTimeout(() => this.#doWork(), 1000);
setTimeout(() => this.#doWork(), this._options.nextTickInterval);
return;
}
@@ -257,7 +263,7 @@ export class SharedQueueConsumer {
envId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -272,7 +278,7 @@ export class SharedQueueConsumer {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -290,7 +296,19 @@ export class SharedQueueConsumer {
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
if (existingTaskRun.status !== "PENDING") {
logger.debug("Task run is not pending, aborting", {
queueMessage: message.data,
messageId: message.messageId,
taskRun: existingTaskRun.id,
status: existingTaskRun.status,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -321,7 +339,7 @@ export class SharedQueueConsumer {
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -332,7 +350,7 @@ export class SharedQueueConsumer {
deployment: deployment.id,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -351,7 +369,7 @@ export class SharedQueueConsumer {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -383,7 +401,7 @@ export class SharedQueueConsumer {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -398,7 +416,7 @@ export class SharedQueueConsumer {
if (!queue) {
await marqs?.nackMessage(message.messageId);
setTimeout(() => this.#doWork(), 1000);
setTimeout(() => this.#doWork(), this._options.nextTickInterval);
return;
}
@@ -417,6 +435,7 @@ export class SharedQueueConsumer {
backgroundWorkerTaskId: backgroundTask.id,
status: "PENDING" as const,
queueId: queue.id,
runtimeEnvironmentId: environment.id,
},
});
@@ -462,7 +481,7 @@ export class SharedQueueConsumer {
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
}
break;
}
@@ -474,41 +493,47 @@ export class SharedQueueConsumer {
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
const resumableRun = await prisma.taskRun.findFirst({
const resumableRun = await prisma.taskRun.findUnique({
where: {
id: message.messageId,
},
});
if (!resumableRun) {
logger.error("Resumable run not found", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
const resumableAttempt = await prisma.taskRunAttempt.findUnique({
where: {
id: messageBody.data.resumableAttemptId,
},
include: {
attempts: {
checkpoints: {
take: 1,
orderBy: {
createdAt: "desc",
},
take: 1,
include: {
checkpoints: {
take: 1,
orderBy: {
createdAt: "desc",
},
},
},
},
},
});
const resumableAttempt = resumableRun?.attempts[0];
if (!resumableAttempt) {
logger.error("Resumable attempt not found", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -523,7 +548,7 @@ export class SharedQueueConsumer {
if (!queue) {
await marqs?.nackMessage(message.messageId);
setTimeout(() => this.#doWork(), 1000);
setTimeout(() => this.#doWork(), this._options.nextTickInterval);
return;
}
@@ -543,7 +568,7 @@ export class SharedQueueConsumer {
resumableAttemptId: resumableAttempt.id,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -553,6 +578,13 @@ export class SharedQueueConsumer {
},
data: {
status: "EXECUTING",
taskRun: {
update: {
data: {
status: "EXECUTING",
},
},
},
},
});
@@ -565,7 +597,7 @@ export class SharedQueueConsumer {
reason: latestCheckpoint.reason ?? undefined,
});
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -593,7 +625,7 @@ export class SharedQueueConsumer {
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -601,7 +633,7 @@ export class SharedQueueConsumer {
if (!completion) {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -614,7 +646,7 @@ export class SharedQueueConsumer {
if (!executionPayload) {
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -641,43 +673,34 @@ export class SharedQueueConsumer {
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
}
break;
}
// Resume after duration-based wait
case "RESUME_AFTER_DURATION": {
const resumableRun = await prisma.taskRun.findFirst({
const resumableAttempt = await prisma.taskRunAttempt.findUnique({
where: {
id: message.messageId,
id: messageBody.data.resumableAttemptId,
},
include: {
attempts: {
checkpoints: {
take: 1,
orderBy: {
createdAt: "desc",
},
take: 1,
include: {
checkpoints: {
take: 1,
orderBy: {
createdAt: "desc",
},
},
},
},
taskRun: true,
},
});
const resumableAttempt = resumableRun?.attempts[0];
if (!resumableAttempt) {
logger.error("Resumable attempt not found", {
queueMessage: message.data,
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -687,7 +710,7 @@ export class SharedQueueConsumer {
messageId: message.messageId,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -702,7 +725,7 @@ export class SharedQueueConsumer {
resumableAttemptId: resumableAttempt.id,
});
await marqs?.acknowledgeMessage(message.messageId);
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
return;
}
@@ -736,7 +759,7 @@ export class SharedQueueConsumer {
// Finally we need to nack the message so it can be retried
await marqs?.nackMessage(message.messageId);
} finally {
setTimeout(() => this.#doWork(), 100);
setTimeout(() => this.#doWork(), this._options.interval);
}
break;
}
@@ -811,14 +834,14 @@ class SharedQueueTasks {
include: {
backgroundWorker: true,
backgroundWorkerTask: true,
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
taskRun: {
include: {
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
tags: true,
batchItem: {
include: {
@@ -836,6 +859,30 @@ class SharedQueueTasks {
return;
}
if (attempt.status === "CANCELED") {
return;
}
if (attempt.status === "FAILED") {
return;
}
if (attempt.status === "COMPLETED") {
return;
}
if (attempt.taskRun.status === "CANCELED") {
return;
}
if (attempt.taskRun.status === "COMPLETED_SUCCESSFULLY") {
return;
}
if (attempt.taskRun.status === "COMPLETED_WITH_ERRORS") {
return;
}
if (setToExecuting) {
await prisma.taskRunAttempt.update({
where: {
@@ -843,6 +890,13 @@ class SharedQueueTasks {
},
data: {
status: "EXECUTING",
taskRun: {
update: {
data: {
status: "EXECUTING",
},
},
},
},
});
}
@@ -877,20 +931,20 @@ class SharedQueueTasks {
name: queue.name,
},
environment: {
id: taskRun.runtimeEnvironment.id,
slug: taskRun.runtimeEnvironment.slug,
type: taskRun.runtimeEnvironment.type,
id: attempt.runtimeEnvironment.id,
slug: attempt.runtimeEnvironment.slug,
type: attempt.runtimeEnvironment.type,
},
organization: {
id: taskRun.runtimeEnvironment.organization.id,
slug: taskRun.runtimeEnvironment.organization.slug,
name: taskRun.runtimeEnvironment.organization.title,
id: attempt.runtimeEnvironment.organization.id,
slug: attempt.runtimeEnvironment.organization.slug,
name: attempt.runtimeEnvironment.organization.title,
},
project: {
id: taskRun.runtimeEnvironment.project.id,
ref: taskRun.runtimeEnvironment.project.externalRef,
slug: taskRun.runtimeEnvironment.project.slug,
name: taskRun.runtimeEnvironment.project.name,
id: attempt.runtimeEnvironment.project.id,
ref: attempt.runtimeEnvironment.project.externalRef,
slug: attempt.runtimeEnvironment.project.slug,
name: attempt.runtimeEnvironment.project.name,
},
batch: taskRun.batchItem?.batchTaskRun
? { id: taskRun.batchItem.batchTaskRun.friendlyId }
@@ -904,8 +958,8 @@ class SharedQueueTasks {
const environmentRepository = new EnvironmentVariablesRepository();
const variables = await environmentRepository.getEnvironmentVariables(
attempt.taskRun.runtimeEnvironment.projectId,
attempt.taskRun.runtimeEnvironmentId
attempt.runtimeEnvironment.projectId,
attempt.runtimeEnvironmentId
);
const payload: ProdTaskRunExecutionPayload = {
@@ -5,6 +5,7 @@ import { BaseService } from "./baseService.server";
import { logger } from "~/services/logger.server";
import { PrismaClientOrTransaction, prisma } from "~/db.server";
import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.server";
export class CancelAttemptService extends BaseService {
public async call(
@@ -49,6 +50,14 @@ export class CancelAttemptService extends BaseService {
},
data: {
status: "CANCELED",
completedAt: cancelledAt,
taskRun: {
update: {
data: {
status: "INTERRUPTED",
},
},
},
},
});
@@ -65,6 +74,10 @@ export class CancelAttemptService extends BaseService {
return eventRepository.cancelEvent(event, cancelledAt, reason);
})
);
if (environment?.type !== "DEVELOPMENT") {
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
}
});
}
}
@@ -78,14 +91,10 @@ async function getAuthenticatedEnvironmentFromAttempt(
friendlyId,
},
include: {
taskRun: {
runtimeEnvironment: {
include: {
runtimeEnvironment: {
include: {
organization: true,
project: true,
},
},
organization: true,
project: true,
},
},
},
@@ -95,5 +104,5 @@ async function getAuthenticatedEnvironmentFromAttempt(
return;
}
return taskRunAttempt?.taskRun.runtimeEnvironment;
return taskRunAttempt?.runtimeEnvironment;
}
@@ -0,0 +1,129 @@
import { TaskRun, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
import { eventRepository } from "../eventRepository.server";
import { marqs } from "../marqs.server";
import { devPubSub } from "../marqs/devPubSub.server";
import { BaseService } from "./baseService.server";
import { socketIo } from "../handleSocketIo.server";
import { assertUnreachable } from "../utils/asserts.server";
import { CancelAttemptService } from "./cancelAttempt.server";
import { logger } from "~/services/logger.server";
const CANCELLABLE_STATUSES: Array<TaskRunStatus> = [
"PENDING",
"EXECUTING",
"PAUSED",
"WAITING_TO_RESUME",
"PAUSED",
"RETRYING_AFTER_FAILURE",
];
const CANCELLABLE_ATTEMPT_STATUSES: Array<TaskRunAttemptStatus> = [
"EXECUTING",
"PAUSED",
"PENDING",
];
export class CancelTaskRunService extends BaseService {
public async call(taskRun: TaskRun) {
// Make sure the task run is in a cancellable state
if (!CANCELLABLE_STATUSES.includes(taskRun.status)) {
return;
}
// Remove the task run from the queue if it's there for some reason
await marqs?.acknowledgeMessage(taskRun.id);
// Set the task run status to cancelled
const cancelledTaskRun = await this._prisma.taskRun.update({
where: {
id: taskRun.id,
},
data: {
status: "CANCELED",
},
include: {
attempts: {
where: {
status: {
in: CANCELLABLE_ATTEMPT_STATUSES,
},
},
include: {
backgroundWorker: true,
runtimeEnvironment: true,
},
},
dependency: true,
runtimeEnvironment: true,
},
});
const inProgressEvents = await eventRepository.queryIncompleteEvents({
runId: taskRun.friendlyId,
});
logger.debug("Cancelling in-progress events", {
inProgressEvents: inProgressEvents.map((event) => event.id),
});
await Promise.all(
inProgressEvents.map((event) => {
return eventRepository.cancelEvent(event, new Date(), "Task run was cancelled by user");
})
);
// Cancel any in progress attempts
for (const attempt of cancelledTaskRun.attempts) {
if (attempt.runtimeEnvironment.type === "DEVELOPMENT") {
// Signal the task run attempt to stop
await devPubSub.publish(
`backgroundWorker:${attempt.backgroundWorkerId}:${attempt.id}`,
"CANCEL_ATTEMPT",
{
attemptId: attempt.friendlyId,
backgroundWorkerId: attempt.backgroundWorker.friendlyId,
taskRunId: cancelledTaskRun.friendlyId,
}
);
} else {
switch (attempt.status) {
case "EXECUTING": {
// We need to send a cancel message to the coordinator
socketIo.coordinatorNamespace.emit("REQUEST_ATTEMPT_CANCELLATION", {
version: "v1",
attemptId: attempt.id,
});
break;
}
case "PENDING":
case "PAUSED": {
logger.debug("Cancelling pending or paused attempt", {
attempt,
});
const service = new CancelAttemptService();
await service.call(
attempt.friendlyId,
taskRun.id,
new Date(),
"Task run was cancelled by user"
);
break;
}
case "CANCELED":
case "COMPLETED":
case "FAILED": {
// Do nothing
break;
}
default: {
assertUnreachable(attempt.status);
}
}
}
}
}
}
@@ -1,72 +1,131 @@
import { Attributes } from "@opentelemetry/api";
import {
RetryOptions,
TaskRunContext,
TaskRunExecution,
TaskRunExecutionResult,
TaskRunFailedExecutionResult,
TaskRunSuccessfulExecutionResult,
defaultRetryOptions,
flattenAttributes,
} from "@trigger.dev/core/v3";
import { PrismaClientOrTransaction } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { safeJsonParse } from "~/utils/json";
import { eventRepository } from "../eventRepository.server";
import { marqs } from "../marqs.server";
import { BaseService } from "./baseService.server";
import { Attributes } from "@opentelemetry/api";
import { logger } from "~/services/logger.server";
import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.server";
import { CancelAttemptService } from "./cancelAttempt.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
export class CompleteAttemptService extends BaseService {
public async call(
completion: TaskRunExecutionResult,
execution: TaskRunExecution,
env?: AuthenticatedEnvironment
): Promise<"ACKNOWLEDGED" | "RETRIED" | "FAILED"> {
const taskRunAttempt = completion.ok
? await this._prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
data: {
status: "COMPLETED",
completedAt: new Date(),
output: completion.output,
outputType: completion.outputType,
},
include: {
taskRun: {
include: {
batchItem: true,
dependency: {
include: {
dependentAttempt: true,
dependentBatchRun: true,
},
},
},
},
backgroundWorkerTask: true,
},
})
: await this._prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
data: {
status: "FAILED",
completedAt: new Date(),
error: completion.error,
},
include: {
taskRun: {
include: {
batchItem: true,
dependency: {
include: {
dependentAttempt: true,
dependentBatchRun: true,
},
},
},
},
backgroundWorkerTask: true,
},
});
) {
const taskRunAttempt = await findAttempt(this._prisma, completion.id);
if (!completion.ok && completion.retry !== undefined) {
if (!taskRunAttempt) {
logger.error("[CompleteAttemptService] Task run attempt not found", { id: completion.id });
// Update the task run to be failed
await this._prisma.taskRun.update({
where: {
friendlyId: execution.run.id,
},
data: {
status: "SYSTEM_FAILURE",
},
});
return "FAILED";
}
if (completion.ok) {
return await this.#completeAttemptSuccessfully(completion, taskRunAttempt, env);
} else {
return await this.#completeAttemptFailed(completion, execution, taskRunAttempt, env);
}
}
async #completeAttemptSuccessfully(
completion: TaskRunSuccessfulExecutionResult,
taskRunAttempt: NonNullable<FoundAttempt>,
env?: AuthenticatedEnvironment
) {
await this._prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
data: {
status: "COMPLETED",
completedAt: new Date(),
output: completion.output,
outputType: completion.outputType,
taskRun: {
update: {
data: {
status: "COMPLETED_SUCCESSFULLY",
},
},
},
},
});
logger.debug("Completed attempt successfully, ACKing message", taskRunAttempt);
await marqs?.acknowledgeMessage(taskRunAttempt.taskRunId);
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: false,
output: completion.output ? (safeJsonParse(completion.output) as Attributes) : undefined,
},
});
if (!env || env.type !== "DEVELOPMENT") {
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
}
return "ACKNOWLEDGED";
}
async #completeAttemptFailed(
completion: TaskRunFailedExecutionResult,
execution: TaskRunExecution,
taskRunAttempt: NonNullable<FoundAttempt>,
env?: AuthenticatedEnvironment
) {
if (
completion.error.type === "INTERNAL_ERROR" &&
completion.error.code === "TASK_RUN_CANCELLED"
) {
// We need to cancel the task run instead of fail it
const cancelService = new CancelAttemptService();
return await cancelService.call(
taskRunAttempt.friendlyId,
taskRunAttempt.taskRunId,
new Date(),
"Cancelled by user",
env
);
}
await this._prisma.taskRunAttempt.update({
where: { friendlyId: completion.id },
data: {
status: "FAILED",
completedAt: new Date(),
error: completion.error,
},
});
if (completion.retry !== undefined) {
const retryConfig = taskRunAttempt.backgroundWorkerTask.retryConfig
? {
...defaultRetryOptions,
@@ -111,6 +170,15 @@ export class CompleteAttemptService extends BaseService {
logger.debug("Retrying", { taskRun: taskRunAttempt.taskRun.friendlyId });
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
status: "RETRYING_AFTER_FAILURE",
},
});
if (environment.type === "DEVELOPMENT") {
// This is already an EXECUTE message so we can just NACK
await marqs?.nackMessage(taskRunAttempt.taskRunId, completion.retry.timestamp);
@@ -127,179 +195,31 @@ export class CompleteAttemptService extends BaseService {
}
return "RETRIED";
}
// Attempt succeeded or this was the last retry
else {
} else {
// No more retries, we need to fail the task run
logger.debug("Completed attempt, ACKing message", taskRunAttempt);
await marqs?.acknowledgeMessage(taskRunAttempt.taskRunId);
// Now we need to "complete" the task run event/span
if (completion.ok) {
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: false,
output: completion.output ? (JSON.parse(completion.output) as Attributes) : undefined,
},
});
} else {
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
});
}
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
});
const { batchItem, dependency } = taskRunAttempt.taskRun;
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
status: "COMPLETED_WITH_ERRORS",
},
});
// This run is part of a batch so we should update its status
if (batchItem) {
logger.debug("Completing attempt with batch item", { batchItem });
await this._prisma.batchTaskRunItem.update({
where: {
id: batchItem.id,
},
data: {
status: completion.ok ? "COMPLETED" : "FAILED",
},
});
const finalizedBatchRun = await this._prisma.batchTaskRun.findFirst({
where: {
id: batchItem.batchTaskRunId,
dependentTaskAttemptId: {
not: null,
},
items: {
every: {
status: {
not: "PENDING",
},
},
},
},
include: {
dependentTaskAttempt: {
include: {
taskRun: true,
},
},
items: {
include: {
taskRun: {
include: {
attempts: {
orderBy: {
completedAt: "desc",
},
take: 1,
select: {
id: true,
},
},
},
},
},
},
},
});
// This batch has a dependent attempt and just finalized, we should resume that attempt
if (finalizedBatchRun && finalizedBatchRun.dependentTaskAttempt) {
const environment =
env ?? (await this.#getEnvironment(taskRunAttempt.taskRun.runtimeEnvironmentId));
if (!environment) {
logger.error("Environment not found", {
attemptId: taskRunAttempt.id,
envId: taskRunAttempt.taskRun.runtimeEnvironmentId,
});
return "FAILED";
}
if (environment.type === "DEVELOPMENT") {
return "ACKNOWLEDGED";
}
const dependentRun = finalizedBatchRun.dependentTaskAttempt.taskRun;
if (finalizedBatchRun.dependentTaskAttempt.status === "PAUSED") {
await marqs?.enqueueMessage(
environment,
dependentRun.queue,
dependentRun.id,
{
type: "RESUME",
completedAttemptIds: [taskRunAttempt.id],
},
dependentRun.concurrencyKey ?? undefined
);
} else {
await marqs?.replaceMessage(dependentRun.id, {
type: "RESUME",
completedAttemptIds: finalizedBatchRun.items.map(
(item) => item.taskRun.attempts[0]?.id
),
});
}
}
}
if (dependency) {
logger.debug("Completing attempt with dependency", { dependency });
const environment =
env ?? (await this.#getEnvironment(taskRunAttempt.taskRun.runtimeEnvironmentId));
if (!environment) {
logger.error("Environment not found", {
attemptId: taskRunAttempt.id,
envId: taskRunAttempt.taskRun.runtimeEnvironmentId,
});
return "FAILED";
}
if (environment.type === "DEVELOPMENT") {
return "ACKNOWLEDGED";
}
if (dependency.dependentAttempt) {
const dependentRun = await this._prisma.taskRun.findFirst({
where: {
id: dependency.dependentAttempt.taskRunId,
},
});
if (!dependentRun) {
logger.error("Dependent task run does not exist", {
attemptId: taskRunAttempt.id,
envId: taskRunAttempt.taskRun.runtimeEnvironmentId,
taskRunId: dependency.taskRunId,
});
return "FAILED";
}
if (dependency.dependentAttempt.status === "PAUSED") {
await marqs?.enqueueMessage(
environment,
dependentRun.queue,
dependentRun.id,
{
type: "RESUME",
completedAttemptIds: [taskRunAttempt.id],
},
dependentRun.concurrencyKey ?? undefined
);
} else {
await marqs?.replaceMessage(dependentRun.id, {
type: "RESUME",
completedAttemptIds: [taskRunAttempt.id],
});
}
}
if (!env || env.type !== "DEVELOPMENT") {
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
}
return "ACKNOWLEDGED";
@@ -329,3 +249,13 @@ export class CompleteAttemptService extends BaseService {
});
}
}
async function findAttempt(prismaClient: PrismaClientOrTransaction, friendlyId: string) {
return prismaClient.taskRunAttempt.findUnique({
where: { friendlyId },
include: {
taskRun: true,
backgroundWorkerTask: true,
},
});
}
@@ -51,7 +51,7 @@ export class CreateCheckpointService {
case "WAIT_FOR_DURATION": {
await marqs?.replaceMessage(
attempt.taskRunId,
{ type: "RESUME_AFTER_DURATION" },
{ type: "RESUME_AFTER_DURATION", resumableAttemptId: attempt.id },
Date.now() + params.reason.ms
);
break;
@@ -0,0 +1,104 @@
import { PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { marqs } from "../marqs.server";
import { BaseService } from "./baseService.server";
export class ResumeBatchRunService extends BaseService {
public async call(batchRunId: string, sourceTaskAttemptId: string) {
const batchRun = await this._prisma.batchTaskRun.findFirst({
where: {
id: batchRunId,
dependentTaskAttemptId: {
not: null,
},
status: "PENDING",
items: {
every: {
taskRunAttemptId: {
not: null,
},
},
},
},
include: {
dependentTaskAttempt: {
include: {
runtimeEnvironment: {
include: {
project: true,
organization: true,
},
},
taskRun: true,
},
},
items: true,
},
});
if (!batchRun || !batchRun.dependentTaskAttempt) {
return;
}
await this._prisma.batchTaskRun.update({
where: {
id: batchRun.id,
},
data: {
status: "COMPLETED",
},
});
// We need to update the batchRun status so we don't resume it again
// This batch has a dependent attempt and just finalized, we should resume that attempt
const environment = batchRun.dependentTaskAttempt.runtimeEnvironment;
// If we are in development, we don't need to resume the dependent task (that will happen automatically)
if (environment.type === "DEVELOPMENT") {
return;
}
const dependentRun = batchRun.dependentTaskAttempt.taskRun;
if (batchRun.dependentTaskAttempt.status === "PAUSED") {
await marqs?.enqueueMessage(
environment,
dependentRun.queue,
dependentRun.id,
{
type: "RESUME",
completedAttemptIds: [sourceTaskAttemptId],
resumableAttemptId: batchRun.dependentTaskAttempt.id,
},
dependentRun.concurrencyKey ?? undefined
);
} else {
await marqs?.replaceMessage(dependentRun.id, {
type: "RESUME",
completedAttemptIds: batchRun.items.map((item) => item.taskRunAttemptId).filter(Boolean),
resumableAttemptId: batchRun.dependentTaskAttempt.id,
});
}
}
static async enqueue(
batchRunId: string,
sourceTaskAttemptId: string,
tx: PrismaClientOrTransaction,
runAt?: Date
) {
return await workerQueue.enqueue(
"v3.resumeBatchRun",
{
batchRunId,
sourceTaskAttemptId,
},
{
tx,
runAt,
queueName: `resumeBatchRun-${batchRunId}`,
}
);
}
}
@@ -0,0 +1,78 @@
import { PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { marqs } from "../marqs.server";
import { BaseService } from "./baseService.server";
export class ResumeTaskDependencyService extends BaseService {
public async call(dependencyId: string, sourceTaskAttemptId: string) {
const dependency = await this._prisma.taskRunDependency.findUnique({
where: { id: dependencyId },
include: {
taskRun: {
include: {
runtimeEnvironment: {
include: {
project: true,
organization: true,
},
},
},
},
dependentAttempt: {
include: {
taskRun: true,
},
},
},
});
// Dependencies with a dependentBatchRun are handled already by the ResumeBatchRunService
if (!dependency || !dependency.dependentAttempt) {
return;
}
if (dependency.taskRun.runtimeEnvironment.type === "DEVELOPMENT") {
return;
}
const dependentRun = dependency.dependentAttempt.taskRun;
if (dependency.dependentAttempt.status === "PAUSED") {
await marqs?.enqueueMessage(
dependency.taskRun.runtimeEnvironment,
dependentRun.queue,
dependentRun.id,
{
type: "RESUME",
completedAttemptIds: [sourceTaskAttemptId],
resumableAttemptId: dependency.dependentAttempt.id,
},
dependentRun.concurrencyKey ?? undefined
);
} else {
await marqs?.replaceMessage(dependentRun.id, {
type: "RESUME",
completedAttemptIds: [sourceTaskAttemptId],
resumableAttemptId: dependency.dependentAttempt.id,
});
}
}
static async enqueue(
dependencyId: string,
sourceTaskAttemptId: string,
tx: PrismaClientOrTransaction,
runAt?: Date
) {
return await workerQueue.enqueue(
"v3.resumeTaskDependency",
{
dependencyId,
sourceTaskAttemptId,
},
{
tx,
runAt,
}
);
}
}
@@ -0,0 +1,86 @@
import { BatchTaskRunItem, TaskRunAttempt, TaskRunDependency } from "@trigger.dev/database";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { workerQueue } from "~/services/worker.server";
import { BaseService } from "./baseService.server";
import { ResumeBatchRunService } from "./resumeBatchRun.server";
import { ResumeTaskDependencyService } from "./resumeTaskDependency.server";
export class ResumeTaskRunDependenciesService extends BaseService {
public async call(attemptId: string) {
const taskAttempt = await this._prisma.taskRunAttempt.findUnique({
where: { id: attemptId },
include: {
taskRun: {
include: {
runtimeEnvironment: true,
batchItem: true,
dependency: {
include: {
dependentAttempt: true,
dependentBatchRun: true,
},
},
},
},
backgroundWorkerTask: true,
},
});
if (!taskAttempt) {
return;
}
if (taskAttempt.taskRun.runtimeEnvironment.type === "DEVELOPMENT") {
return;
}
const { batchItem, dependency } = taskAttempt.taskRun;
if (!batchItem && !dependency) {
return;
}
if (batchItem) {
await this.#resumeBatchItem(batchItem, taskAttempt);
return;
}
if (dependency && dependency.dependentAttempt) {
await this.#resumeDependency(dependency, taskAttempt);
}
}
async #resumeBatchItem(batchItem: BatchTaskRunItem, taskAttempt: TaskRunAttempt) {
await $transaction(this._prisma, async (tx) => {
await tx.batchTaskRunItem.update({
where: {
id: batchItem.id,
},
data: {
status: "COMPLETED",
taskRunAttemptId: taskAttempt.id,
},
});
await ResumeBatchRunService.enqueue(batchItem.batchTaskRunId, taskAttempt.id, tx);
});
}
async #resumeDependency(dependency: TaskRunDependency, taskAttempt: TaskRunAttempt) {
await ResumeTaskDependencyService.enqueue(dependency.id, taskAttempt.id, this._prisma);
}
static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
return await workerQueue.enqueue(
"v3.resumeTaskRunDependencies",
{
attemptId,
},
{
tx,
runAt,
jobKey: `resumeTaskRunDependencies:${attemptId}`,
}
);
}
}
@@ -100,6 +100,7 @@ export class TriggerTaskService extends BaseService {
const taskRun = await tx.taskRun.create({
data: {
status: "PENDING",
number: counter.lastNumber,
friendlyId: generateFriendlyId("run"),
runtimeEnvironmentId: environment.id,
+4 -1
View File
@@ -47,7 +47,10 @@ export class SharedSocketConnection {
},
});
this._sharedConsumer = new SharedQueueConsumer(this._sender);
this._sharedConsumer = new SharedQueueConsumer(this._sender, {
interval: 100,
nextTickInterval: 1000,
});
socket.on("disconnect", this.#handleClose.bind(this));
socket.on("error", this.#handleError.bind(this));
@@ -0,0 +1,3 @@
export function assertUnreachable(x: never): never {
throw new Error("Didn't expect to get here");
}
@@ -0,0 +1,117 @@
import { Logger } from "@trigger.dev/core-backend";
import { ZodMessageCatalogSchema, ZodMessageHandler, ZodMessageSender } from "@trigger.dev/core/v3";
import Redis, { RedisOptions } from "ioredis";
import { z } from "zod";
import { logger } from "~/services/logger.server";
import { safeJsonParse } from "~/utils/json";
export type ZodPubSubOptions<TMessageCatalog extends ZodMessageCatalogSchema> = {
redis: RedisOptions;
schema: TMessageCatalog;
};
export interface ZodSubscriber<TMessageCatalog extends ZodMessageCatalogSchema> {
on<K extends keyof TMessageCatalog>(
eventName: K,
listener: (payload: z.infer<TMessageCatalog[K]>) => Promise<void>
): void;
stopListening(): Promise<void>;
}
class RedisZodSubscriber<TMessageCatalog extends ZodMessageCatalogSchema>
implements ZodSubscriber<TMessageCatalog>
{
private _subscriber: Redis;
private _listeners: Map<string, (payload: unknown) => Promise<void>> = new Map();
private _messageHandler: ZodMessageHandler<TMessageCatalog>;
constructor(
private readonly _pattern: string,
private readonly _options: ZodPubSubOptions<TMessageCatalog>,
private readonly _logger: Logger
) {
this._subscriber = new Redis(_options.redis);
this._messageHandler = new ZodMessageHandler({
schema: _options.schema,
});
}
async initialize() {
await this._subscriber.psubscribe(this._pattern);
this._subscriber.on("pmessage", this.#onMessage.bind(this));
}
public on<K extends keyof TMessageCatalog>(
eventName: K,
listener: (payload: z.infer<TMessageCatalog[K]>) => Promise<void>
): void {
this._listeners.set(eventName as string, listener);
}
public async stopListening(): Promise<void> {
this._listeners.clear();
await this._subscriber.unsubscribe();
}
async #onMessage(pattern: string, channel: string, serializedMessage: string) {
if (pattern !== this._pattern) {
return;
}
const parsedMessage = safeJsonParse(serializedMessage);
if (!parsedMessage) {
return;
}
const message = this._messageHandler.parseMessage(parsedMessage);
if (typeof message.type !== "string") {
return;
}
const listener = this._listeners.get(message.type);
if (!listener) {
this._logger.debug(`No listener for message type: ${message.type}`, { parsedMessage });
return;
}
try {
await listener(message.payload);
} catch (error) {
this._logger.error("Error handling message", { error, message });
}
}
}
export class ZodPubSub<TMessageCatalog extends ZodMessageCatalogSchema> {
private _publisher: Redis;
private _logger = logger.child({ module: "ZodPubSub" });
constructor(private _options: ZodPubSubOptions<TMessageCatalog>) {
this._publisher = new Redis(_options.redis);
}
public async publish<K extends keyof TMessageCatalog>(
channel: string,
type: K,
payload: z.input<TMessageCatalog[K]>
): Promise<void> {
try {
await this._publisher.publish(channel, JSON.stringify({ type, payload, version: "v1" }));
} catch (e) {
logger.error("Failed to publish message", { channel, type, payload, error: e });
}
}
public async subscribe(channel: string): Promise<ZodSubscriber<TMessageCatalog>> {
const subscriber = new RedisZodSubscriber(channel, this._options, this._logger);
await subscriber.initialize();
return subscriber;
}
}
@@ -108,9 +108,23 @@ export class BackgroundWorkerCoordinator {
}
async handleMessage(id: string, message: BackgroundWorkerServerMessages) {
logger.debug(`Received message from worker ${id}`, { workerMessage: message });
switch (message.type) {
case "EXECUTE_RUNS": {
await Promise.all(message.payloads.map((payload) => this.#executeTaskRun(id, payload)));
break;
}
case "CANCEL_ATTEMPT": {
// Need to cancel the attempt somehow here
const worker = this._backgroundWorkers.get(id);
if (!worker) {
logger.error(`Could not find worker ${id}`);
return;
}
await worker.cancelRun(message.taskRunId);
}
}
}
@@ -158,7 +172,8 @@ export class BackgroundWorkerCoordinator {
const resultText = !completion.ok
? completion.error.type === "INTERNAL_ERROR" &&
completion.error.code === TaskRunErrorCodes.TASK_EXECUTION_ABORTED
(completion.error.code === TaskRunErrorCodes.TASK_EXECUTION_ABORTED ||
completion.error.code === TaskRunErrorCodes.TASK_RUN_CANCELLED)
? chalk.yellow("cancelled")
: chalk.red(`error${retryingText}`)
: chalk.green("success");
@@ -214,6 +229,14 @@ class CleanupProcessError extends Error {
}
}
class CancelledProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CancelledProcessError";
}
}
export type BackgroundWorkerParams = {
env: Record<string, string>;
dependencies?: Record<string, string>;
@@ -367,6 +390,16 @@ export class BackgroundWorker {
return this._taskRunProcesses.get(payload.execution.run.id) as TaskRunProcess;
}
async cancelRun(taskRunId: string) {
const taskRunProcess = this._taskRunProcesses.get(taskRunId);
if (!taskRunProcess) {
return;
}
await taskRunProcess.cancel();
}
// We need to fork the process before we can execute any tasks
async executeTaskRun(payload: TaskRunExecutionPayload): Promise<TaskRunExecutionResult> {
try {
@@ -393,6 +426,18 @@ export class BackgroundWorker {
return result;
} catch (e) {
if (e instanceof CancelledProcessError) {
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
},
};
}
if (e instanceof CleanupProcessError) {
return {
id: payload.execution.attempt.id,
@@ -464,6 +509,7 @@ class TaskRunProcess {
private _attemptStatuses: Map<string, "PENDING" | "REJECTED" | "RESOLVED"> = new Map();
private _currentExecution: TaskRunExecution | undefined;
private _isBeingKilled: boolean = false;
private _isBeingCancelled: boolean = false;
public onTaskHeartbeat: Evt<string> = new Evt();
public onExit: Evt<number> = new Evt();
@@ -483,6 +529,12 @@ class TaskRunProcess {
});
}
async cancel() {
this._isBeingCancelled = true;
await this.cleanup(true);
}
async initialize() {
this._child = fork(this.path, {
stdio: [/*stdin*/ "ignore", /*stdout*/ "pipe", /*stderr*/ "pipe", "ipc"],
@@ -617,7 +669,9 @@ class TaskRunProcess {
const { rejecter } = attemptPromise;
if (this._isBeingKilled) {
if (this._isBeingCancelled) {
rejecter(new CancelledProcessError());
} else if (this._isBeingKilled) {
rejecter(new CleanupProcessError());
} else {
rejecter(new UnexpectedExitError(code));
@@ -36,6 +36,14 @@ class CleanupProcessError extends Error {
}
}
class CancelledProcessError extends Error {
constructor() {
super("Cancelled");
this.name = "CancelledProcessError";
}
}
type BackgroundWorkerParams = {
env: Record<string, string>;
projectConfig: Config;
@@ -58,7 +66,7 @@ export class ProdBackgroundWorker {
public tasks: Array<TaskMetadataWithFilePath> = [];
_taskRunProcesses: Map<string, TaskRunProcess> = new Map();
_taskRunProcess: TaskRunProcess | undefined;
private _closed: boolean = false;
@@ -77,9 +85,7 @@ export class ProdBackgroundWorker {
this.onTaskHeartbeat.detach();
// We need to close all the task run processes
for (const taskRunProcess of this._taskRunProcesses.values()) {
taskRunProcess.cleanup(true);
}
this._taskRunProcess?.cleanup(true);
// Delete worker files
this._onClose.post();
@@ -173,14 +179,11 @@ export class ProdBackgroundWorker {
completion: TaskRunExecutionResult,
execution: TaskRunExecution
) {
for (const taskRunProcess of this._taskRunProcesses.values()) {
taskRunProcess.taskRunCompletedNotification(completion, execution);
}
this._taskRunProcess?.taskRunCompletedNotification(completion, execution);
}
async waitCompletedNotification() {
for (const taskRunProcess of this._taskRunProcesses.values()) {
taskRunProcess.waitCompletedNotification();
}
this._taskRunProcess?.waitCompletedNotification();
}
async #initializeTaskRunProcess(payload: ProdTaskRunExecutionPayload): Promise<TaskRunProcess> {
@@ -189,47 +192,45 @@ export class ProdBackgroundWorker {
payload.execution.worker.version
);
if (!this._taskRunProcesses.has(payload.execution.run.id)) {
const taskRunProcess = new TaskRunProcess(
this.path,
{
...this.params.env,
...(payload.environment ?? {}),
},
metadata,
this.params
);
const taskRunProcess = new TaskRunProcess(
this.path,
{
...this.params.env,
...(payload.environment ?? {}),
},
metadata,
this.params
);
taskRunProcess.onExit.attach(() => {
this._taskRunProcesses.delete(payload.execution.run.id);
});
this._taskRunProcess = taskRunProcess;
taskRunProcess.onTaskHeartbeat.attach((id) => {
this.onTaskHeartbeat.post(id);
});
taskRunProcess.onExit.attach(() => {
this._taskRunProcess = undefined;
});
taskRunProcess.onWaitForBatch.attach((message) => {
this.onWaitForBatch.post(message);
});
taskRunProcess.onTaskHeartbeat.attach((id) => {
this.onTaskHeartbeat.post(id);
});
taskRunProcess.onWaitForDuration.attach((message) => {
this.onWaitForDuration.post(message);
});
taskRunProcess.onWaitForBatch.attach((message) => {
this.onWaitForBatch.post(message);
});
taskRunProcess.onWaitForTask.attach((message) => {
this.onWaitForTask.post(message);
});
taskRunProcess.onWaitForDuration.attach((message) => {
this.onWaitForDuration.post(message);
});
this.preCheckpointNotification.attach((message) => {
taskRunProcess.preCheckpointNotification.post(message);
});
taskRunProcess.onWaitForTask.attach((message) => {
this.onWaitForTask.post(message);
});
await taskRunProcess.initialize();
this.preCheckpointNotification.attach((message) => {
taskRunProcess.preCheckpointNotification.post(message);
});
this._taskRunProcesses.set(payload.execution.run.id, taskRunProcess);
}
await taskRunProcess.initialize();
return this._taskRunProcesses.get(payload.execution.run.id) as TaskRunProcess;
return taskRunProcess;
}
// We need to fork the process before we can execute any tasks
@@ -259,6 +260,18 @@ export class ProdBackgroundWorker {
return result;
} catch (e) {
if (e instanceof CancelledProcessError) {
return {
id: payload.execution.attempt.id,
ok: false,
retry: undefined,
error: {
type: "INTERNAL_ERROR",
code: TaskRunErrorCodes.TASK_RUN_CANCELLED,
},
};
}
if (e instanceof CleanupProcessError) {
return {
id: payload.execution.attempt.id,
@@ -295,6 +308,10 @@ export class ProdBackgroundWorker {
}
}
async cancelAttempt(attemptId: string) {
await this._taskRunProcess?.cancel();
}
async #correctError(
error: TaskRunBuiltInError,
execution: TaskRunExecution
@@ -320,6 +337,7 @@ class TaskRunProcess {
private _attemptStatuses: Map<string, "PENDING" | "REJECTED" | "RESOLVED"> = new Map();
private _currentExecution: TaskRunExecution | undefined;
private _isBeingKilled: boolean = false;
private _isBeingCancelled: boolean = false;
public onTaskHeartbeat: Evt<string> = new Evt();
public onExit: Evt<number> = new Evt();
@@ -405,6 +423,12 @@ class TaskRunProcess {
this._child.stderr?.on("data", this.#handleStdErr.bind(this));
}
async cancel() {
this._isBeingCancelled = true;
await this.cleanup(true);
}
async cleanup(kill: boolean = false) {
if (kill && this._isBeingKilled) {
return;
@@ -484,7 +508,9 @@ class TaskRunProcess {
const { rejecter } = attemptPromise;
if (this._isBeingKilled) {
if (this._isBeingCancelled) {
rejecter(new CancelledProcessError());
} else if (this._isBeingKilled) {
rejecter(new CleanupProcessError());
} else {
rejecter(new UnexpectedExitError(code));
@@ -200,6 +200,16 @@ class ProdWorker {
process.exit(0);
},
REQUEST_ATTEMPT_CANCELLATION: async (message) => {
if (!this.executing) {
return;
}
await this.#backgroundWorker.cancelAttempt(message.attemptId);
},
REQUEST_EXIT: async () => {
process.exit(0);
},
},
onConnection: async (socket, handler, sender, logger) => {
if (process.env.INDEX_TASKS === "true") {
+10
View File
@@ -37,6 +37,16 @@ export class Logger {
this.#additionalFields = additionalFields ?? (() => ({}));
}
child(fields: Record<string, unknown>) {
return new Logger(
this.#name,
logLevels[this.#level],
this.#filteredKeys,
this.#jsonReplacer,
() => ({ ...this.#additionalFields(), ...fields })
);
}
// Return a new Logger instance with the same name and a new log level
// but filter out the keys from the log messages (at any level)
filter(...keys: string[]) {
+2
View File
@@ -30,6 +30,7 @@ export const TaskRunErrorCodes = {
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_RUN_CANCELLED: "TASK_RUN_CANCELLED",
} as const;
export const TaskRunInternalError = z.object({
@@ -41,6 +42,7 @@ export const TaskRunInternalError = z.object({
"TASK_EXECUTION_FAILED",
"TASK_EXECUTION_ABORTED",
"TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE",
"TASK_RUN_CANCELLED",
]),
});
+5
View File
@@ -32,6 +32,11 @@ export const BackgroundWorkerServerMessages = z.discriminatedUnion("type", [
type: z.literal("EXECUTE_RUNS"),
payloads: z.array(TaskRunExecutionPayload),
}),
z.object({
type: z.literal("CANCEL_ATTEMPT"),
taskAttemptId: z.string(),
taskRunId: z.string(),
}),
z.object({
type: z.literal("SCHEDULE_ATTEMPT"),
id: z.string(),
+17
View File
@@ -222,6 +222,12 @@ export const PlatformToCoordinatorMessages = {
attemptId: z.string(),
}),
},
REQUEST_ATTEMPT_CANCELLATION: {
message: z.object({
version: z.literal("v1").default("v1"),
attemptId: z.string(),
}),
},
};
export const ClientToSharedQueueMessages = {
@@ -375,6 +381,17 @@ export const CoordinatorToProdWorkerMessages = {
executionPayload: ProdTaskRunExecutionPayload,
}),
},
REQUEST_ATTEMPT_CANCELLATION: {
message: z.object({
version: z.literal("v1").default("v1"),
attemptId: z.string(),
}),
},
REQUEST_EXIT: {
message: z.object({
version: z.literal("v1").default("v1"),
}),
},
};
export const ProdWorkerSocketData = z.object({
+30 -4
View File
@@ -17,7 +17,7 @@ export type ZodMessageHandlerOptions<TMessageCatalog extends ZodMessageCatalogSc
messages?: ZodMessageHandlers<TMessageCatalog>;
};
type MessageFromSchema<
export type MessageFromSchema<
K extends keyof TMessageCatalog,
TMessageCatalog extends ZodMessageCatalogSchema,
> = {
@@ -25,11 +25,11 @@ type MessageFromSchema<
payload: z.input<TMessageCatalog[K]>;
};
type MessageFromCatalog<TMessageCatalog extends ZodMessageCatalogSchema> = {
export type MessageFromCatalog<TMessageCatalog extends ZodMessageCatalogSchema> = {
[K in keyof TMessageCatalog]: MessageFromSchema<K, TMessageCatalog>;
}[keyof TMessageCatalog];
const messageSchema = z.object({
export const ZodMessageSchema = z.object({
version: z.literal("v1").default("v1"),
type: z.string(),
payload: z.unknown(),
@@ -68,7 +68,7 @@ export class ZodMessageHandler<TMessageCatalog extends ZodMessageCatalogSchema>
}
public parseMessage(message: unknown): MessageFromCatalog<TMessageCatalog> {
const parsedMessage = messageSchema.safeParse(message);
const parsedMessage = ZodMessageSchema.safeParse(message);
if (!parsedMessage.success) {
throw new Error(`Failed to parse message: ${JSON.stringify(parsedMessage.error)}`);
@@ -161,6 +161,32 @@ export class ZodMessageSender<TMessageCatalog extends ZodMessageCatalogSchema> {
await this.#sender({ type, payload, version: "v1" });
}
public async forwardMessage(message: unknown) {
const parsedMessage = ZodMessageSchema.safeParse(message);
if (!parsedMessage.success) {
throw new Error(`Failed to parse message: ${JSON.stringify(parsedMessage.error)}`);
}
const schema = this.#schema[parsedMessage.data.type];
if (!schema) {
throw new Error(`Unknown message type: ${parsedMessage.data.type}`);
}
const parsedPayload = schema.safeParse(parsedMessage.data.payload);
if (!parsedPayload.success) {
throw new Error(`Failed to parse message payload: ${JSON.stringify(parsedPayload.error)}`);
}
await this.#sender({
type: parsedMessage.data.type,
payload: parsedPayload.data,
version: "v1",
});
}
}
export type MessageCatalogToSocketIoEvents<TCatalog extends ZodMessageCatalogSchema> = {
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "TaskRunStatus" AS ENUM ('PENDING', 'EXECUTING', 'WAITING_TO_RESUME', 'RETRYING_AFTER_FAILURE', 'PAUSED', 'CANCELED', 'COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS');
-- AlterTable
ALTER TABLE "TaskRun" ADD COLUMN "status" "TaskRunStatus" NOT NULL DEFAULT 'PENDING';
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "TaskRunStatus" ADD VALUE 'INTERRUPTED';
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "TaskRunStatus" ADD VALUE 'SYSTEM_FAILURE';
@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "BatchTaskRunStatus" AS ENUM ('PENDING', 'COMPLETED');
-- AlterTable
ALTER TABLE "BatchTaskRun" ADD COLUMN "status" "BatchTaskRunStatus" NOT NULL DEFAULT 'PENDING';
@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "BatchTaskRunItem" ADD COLUMN "taskRunAttemptId" TEXT;
-- AddForeignKey
ALTER TABLE "BatchTaskRunItem" ADD CONSTRAINT "BatchTaskRunItem_taskRunAttemptId_fkey" FOREIGN KEY ("taskRunAttemptId") REFERENCES "TaskRunAttempt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,11 @@
/*
Warnings:
- Added the required column `runtimeEnvironmentId` to the `TaskRunAttempt` table without a default value. This is not possible if the table is not empty.
*/
-- AlterTable
ALTER TABLE "TaskRunAttempt" ADD COLUMN "runtimeEnvironmentId" TEXT NOT NULL;
-- AddForeignKey
ALTER TABLE "TaskRunAttempt" ADD CONSTRAINT "TaskRunAttempt_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+50 -1
View File
@@ -393,6 +393,7 @@ model RuntimeEnvironment {
checkpoints Checkpoint[]
workerDeployments WorkerDeployment[]
workerDeploymentPromotions WorkerDeploymentPromotion[]
taskRunAttempts TaskRunAttempt[]
@@unique([projectId, slug, orgMemberId])
@@unique([projectId, shortcode])
@@ -1564,6 +1565,8 @@ model TaskRun {
number Int @default(0)
friendlyId String @unique
status TaskRunStatus @default(PENDING)
idempotencyKey String
taskIdentifier String
@@ -1606,6 +1609,38 @@ model TaskRun {
@@unique([runtimeEnvironmentId, idempotencyKey])
}
enum TaskRunStatus {
/// Task is waiting to be executed by a worker
PENDING
/// Task is currently being executed by a worker
EXECUTING
/// Task has been paused by the system, and will be resumed by the system
WAITING_TO_RESUME
/// Task has failed and is waiting to be retried
RETRYING_AFTER_FAILURE
/// Task has been paused by the user, and can be resumed by the user
PAUSED
/// Task has been canceled by the user
CANCELED
/// Task was interrupted during execution, mostly this happens in development environments
INTERRUPTED
/// Task has been completed successfully
COMPLETED_SUCCESSFULLY
/// Task has been completed with errors
COMPLETED_WITH_ERRORS
/// Task has failed to complete, due to an error in the system
SYSTEM_FAILURE
}
model TaskRunDependency {
id String @id @default(cuid())
@@ -1660,6 +1695,9 @@ model TaskRunAttempt {
backgroundWorkerTask BackgroundWorkerTask @relation(fields: [backgroundWorkerTaskId], references: [id], onDelete: Cascade, onUpdate: Cascade)
backgroundWorkerTaskId String
runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade)
runtimeEnvironmentId String
queue TaskQueue @relation(fields: [queueId], references: [id], onDelete: Cascade, onUpdate: Cascade)
queueId String
@@ -1678,7 +1716,8 @@ model TaskRunAttempt {
taskRunDependency TaskRunDependency? @relation("dependentAttempt")
batchTaskRunDependency BatchTaskRun?
checkpoints Checkpoint[]
checkpoints Checkpoint[]
batchTaskRunItems BatchTaskRunItem[]
@@unique([taskRunId, number])
}
@@ -1826,6 +1865,8 @@ model BatchTaskRun {
friendlyId String @unique
status BatchTaskRunStatus @default(PENDING)
idempotencyKey String
taskIdentifier String
@@ -1844,6 +1885,11 @@ model BatchTaskRun {
@@unique([runtimeEnvironmentId, idempotencyKey])
}
enum BatchTaskRunStatus {
PENDING
COMPLETED
}
model BatchTaskRunItem {
id String @id @default(cuid())
@@ -1855,6 +1901,9 @@ model BatchTaskRunItem {
taskRun TaskRun @relation(fields: [taskRunId], references: [id], onDelete: Cascade, onUpdate: Cascade)
taskRunId String @unique
taskRunAttempt TaskRunAttempt? @relation(fields: [taskRunAttemptId], references: [id], onDelete: SetNull, onUpdate: Cascade)
taskRunAttemptId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -13,3 +13,16 @@ export const longRunning = task({
};
},
});
export const longRunningParent = task({
id: "long-running-parent",
run: async (payload: { message: string }) => {
logger.info("Long running parent", { payload });
await longRunning.triggerAndWait({ payload: { message: "child" } });
return {
finished: new Date().toISOString(),
};
},
});
@@ -4,6 +4,9 @@ import { interceptor } from "./utils/interceptor";
export const taskWithRetries = task({
id: "task-with-retries",
retry: {
maxAttempts: 4,
},
run: async (payload: any, { ctx }) => {
const result = await retry.onThrow(
async ({ attempt }) => {
@@ -43,6 +46,13 @@ export const taskWithRetries = task({
},
});
export const taskThatErrors = task({
id: "task-that-errors",
run: async (payload: any, { ctx }) => {
throw new Error("failed");
},
});
export const taskWithFetchRetries = task({
id: "task-with-fetch-retries",
middleware: (payload: any, { next }) => {