Added SDK and API support to stop runs when they’re canceled
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@trigger.dev/sdk": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Added support for Runs being canceled
|
||||||
@@ -117,7 +117,8 @@ export class CompleteRunTaskService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
existingTask.status === "COMPLETED" ||
|
existingTask.status === "COMPLETED" ||
|
||||||
existingTask.status === "ERRORED"
|
existingTask.status === "ERRORED" ||
|
||||||
|
existingTask.status === "CANCELED"
|
||||||
) {
|
) {
|
||||||
logger.debug("Task already completed", {
|
logger.debug("Task already completed", {
|
||||||
existingTask,
|
existingTask,
|
||||||
|
|||||||
@@ -121,7 +121,8 @@ export class FailRunTaskService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
existingTask.status === "COMPLETED" ||
|
existingTask.status === "COMPLETED" ||
|
||||||
existingTask.status === "ERRORED"
|
existingTask.status === "ERRORED" ||
|
||||||
|
existingTask.status === "CANCELED"
|
||||||
) {
|
) {
|
||||||
logger.debug("Task already completed", {
|
logger.debug("Task already completed", {
|
||||||
existingTask,
|
existingTask,
|
||||||
|
|||||||
@@ -11,21 +11,42 @@ export class CancelRunService {
|
|||||||
public async call({ runId }: { runId: string }) {
|
public async call({ runId }: { runId: string }) {
|
||||||
try {
|
try {
|
||||||
return await this.#prismaClient.$transaction(async (tx) => {
|
return await this.#prismaClient.$transaction(async (tx) => {
|
||||||
const run = await tx.jobRun.update({
|
const run = await tx.jobRun.findUniqueOrThrow({
|
||||||
select: {
|
where: {
|
||||||
queueId: true,
|
id: runId,
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldDecrementQueue =
|
||||||
|
run.status === "STARTED" || run.status === "PREPROCESSING";
|
||||||
|
await tx.jobRun.update({
|
||||||
where: { id: runId },
|
where: { id: runId },
|
||||||
data: {
|
data: {
|
||||||
status: "CANCELED",
|
status: "CANCELED",
|
||||||
queue: {
|
completedAt: new Date(),
|
||||||
update: {
|
queue: shouldDecrementQueue
|
||||||
jobCount: {
|
? {
|
||||||
decrement: 1,
|
update: {
|
||||||
},
|
jobCount: {
|
||||||
},
|
decrement: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.task.updateMany({
|
||||||
|
where: {
|
||||||
|
runId,
|
||||||
|
status: {
|
||||||
|
in: ["PENDING", "RUNNING", "WAITING"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
data: {
|
||||||
|
status: "CANCELED",
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await workerQueue.enqueue(
|
await workerQueue.enqueue(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Task } from "@trigger.dev/database";
|
|||||||
import {
|
import {
|
||||||
ApiEventLogSchema,
|
ApiEventLogSchema,
|
||||||
CachedTaskSchema,
|
CachedTaskSchema,
|
||||||
|
RunJobCanceledWithTask,
|
||||||
RunJobError,
|
RunJobError,
|
||||||
RunJobResumeWithTask,
|
RunJobResumeWithTask,
|
||||||
RunJobRetryWithTask,
|
RunJobRetryWithTask,
|
||||||
@@ -193,6 +194,11 @@ export class PerformRunExecutionService {
|
|||||||
async #executeJob(execution: FoundRunExecution) {
|
async #executeJob(execution: FoundRunExecution) {
|
||||||
const { run } = execution;
|
const { run } = execution;
|
||||||
|
|
||||||
|
if (run.status === "CANCELED") {
|
||||||
|
await this.#cancelExecution(execution);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const client = new EndpointApi(
|
const client = new EndpointApi(
|
||||||
run.environment.apiKey,
|
run.environment.apiKey,
|
||||||
run.endpoint.url,
|
run.endpoint.url,
|
||||||
@@ -339,6 +345,10 @@ export class PerformRunExecutionService {
|
|||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "CANCELED": {
|
||||||
|
await this.#cancelExecution(execution);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
const _exhaustiveCheck: never = status;
|
const _exhaustiveCheck: never = status;
|
||||||
throw new Error(`Non-exhaustive match for value: ${status}`);
|
throw new Error(`Non-exhaustive match for value: ${status}`);
|
||||||
@@ -704,6 +714,19 @@ export class PerformRunExecutionService {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #cancelExecution(execution: FoundRunExecution) {
|
||||||
|
await this.#prismaClient.jobRunExecution.update({
|
||||||
|
where: {
|
||||||
|
id: execution.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
status: "FAILURE",
|
||||||
|
completedAt: new Date(),
|
||||||
|
error: "This never ran because it was canceled by the user.",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function findRunExecution(prisma: PrismaClientOrTransaction, id: string) {
|
async function findRunExecution(prisma: PrismaClientOrTransaction, id: string) {
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ new Job(client, {
|
|||||||
body: z.any().optional(),
|
body: z.any().optional(),
|
||||||
retry: z.any().optional(),
|
retry: z.any().optional(),
|
||||||
}),
|
}),
|
||||||
|
examples: {
|
||||||
|
successfulRequest: {
|
||||||
|
url: "https://httpbin.org/status/200",
|
||||||
|
method: "GET",
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
run: async (payload, io, ctx) => {
|
run: async (payload, io, ctx) => {
|
||||||
return await io.backgroundFetch<any>(
|
return await io.backgroundFetch<any>(
|
||||||
@@ -635,10 +641,14 @@ new Job(client, {
|
|||||||
repo: "basic-starter-12k",
|
repo: "basic-starter-12k",
|
||||||
}),
|
}),
|
||||||
run: async (payload, io, ctx) => {
|
run: async (payload, io, ctx) => {
|
||||||
await io.wait("wait", 5); // wait for 5 seconds
|
await io.runTask("slow task", { name: "slow task" }, async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||||
|
});
|
||||||
|
|
||||||
await io.logger.info("This is a simple log info message");
|
await io.logger.info("This is a simple log info message");
|
||||||
|
|
||||||
|
await io.wait("wait", 5); // wait for 5 seconds
|
||||||
|
|
||||||
const response = await io.slack.postMessage("Slack 📝", {
|
const response = await io.slack.postMessage("Slack 📝", {
|
||||||
text: `New Issue opened: ${payload.issue.html_url}`,
|
text: `New Issue opened: ${payload.issue.html_url}`,
|
||||||
channel: "C04GWUTDC3W",
|
channel: "C04GWUTDC3W",
|
||||||
|
|||||||
@@ -356,6 +356,15 @@ export const RunJobRetryWithTaskSchema = z.object({
|
|||||||
|
|
||||||
export type RunJobRetryWithTask = z.infer<typeof RunJobRetryWithTaskSchema>;
|
export type RunJobRetryWithTask = z.infer<typeof RunJobRetryWithTaskSchema>;
|
||||||
|
|
||||||
|
export const RunJobCanceledWithTaskSchema = z.object({
|
||||||
|
status: z.literal("CANCELED"),
|
||||||
|
task: TaskSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
export type RunJobCanceledWithTask = z.infer<
|
||||||
|
typeof RunJobCanceledWithTaskSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export const RunJobSuccessSchema = z.object({
|
export const RunJobSuccessSchema = z.object({
|
||||||
status: z.literal("SUCCESS"),
|
status: z.literal("SUCCESS"),
|
||||||
output: DeserializedJsonSchema.optional(),
|
output: DeserializedJsonSchema.optional(),
|
||||||
@@ -367,6 +376,7 @@ export const RunJobResponseSchema = z.discriminatedUnion("status", [
|
|||||||
RunJobErrorSchema,
|
RunJobErrorSchema,
|
||||||
RunJobResumeWithTaskSchema,
|
RunJobResumeWithTaskSchema,
|
||||||
RunJobRetryWithTaskSchema,
|
RunJobRetryWithTaskSchema,
|
||||||
|
RunJobCanceledWithTaskSchema,
|
||||||
RunJobSuccessSchema,
|
RunJobSuccessSchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ export class RetryWithTaskError {
|
|||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CanceledWithTaskError {
|
||||||
|
constructor(public task: ServerTask) {}
|
||||||
|
}
|
||||||
|
|
||||||
/** Use this function if you're using a `try/catch` block to catch errors.
|
/** Use this function if you're using a `try/catch` block to catch errors.
|
||||||
* It checks if a thrown error is a special internal error that you should ignore.
|
* It checks if a thrown error is a special internal error that you should ignore.
|
||||||
* If this returns `true` then you must rethrow the error: `throw err;`
|
* If this returns `true` then you must rethrow the error: `throw err;`
|
||||||
@@ -20,8 +24,10 @@ export class RetryWithTaskError {
|
|||||||
*/
|
*/
|
||||||
export function isTriggerError(
|
export function isTriggerError(
|
||||||
err: unknown
|
err: unknown
|
||||||
): err is ResumeWithTaskError | RetryWithTaskError {
|
): err is ResumeWithTaskError | RetryWithTaskError | CanceledWithTaskError {
|
||||||
return (
|
return (
|
||||||
err instanceof ResumeWithTaskError || err instanceof RetryWithTaskError
|
err instanceof ResumeWithTaskError ||
|
||||||
|
err instanceof RetryWithTaskError ||
|
||||||
|
err instanceof CanceledWithTaskError
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|||||||
import { webcrypto } from "node:crypto";
|
import { webcrypto } from "node:crypto";
|
||||||
import { ApiClient } from "./apiClient";
|
import { ApiClient } from "./apiClient";
|
||||||
import {
|
import {
|
||||||
|
CanceledWithTaskError,
|
||||||
ResumeWithTaskError,
|
ResumeWithTaskError,
|
||||||
RetryWithTaskError,
|
RetryWithTaskError,
|
||||||
isTriggerError,
|
isTriggerError,
|
||||||
@@ -513,6 +514,15 @@ export class IO {
|
|||||||
parentId,
|
parentId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (task.status === "CANCELED") {
|
||||||
|
this._logger.debug("Task canceled", {
|
||||||
|
idempotencyKey,
|
||||||
|
task,
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new CanceledWithTaskError(task);
|
||||||
|
}
|
||||||
|
|
||||||
if (task.status === "COMPLETED") {
|
if (task.status === "COMPLETED") {
|
||||||
this._logger.debug("Using task output", {
|
this._logger.debug("Using task output", {
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -560,10 +570,18 @@ export class IO {
|
|||||||
task,
|
task,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this._apiClient.completeTask(this._id, task.id, {
|
const completedTask = await this._apiClient.completeTask(
|
||||||
output: result ?? undefined,
|
this._id,
|
||||||
properties: task.outputProperties ?? undefined,
|
task.id,
|
||||||
});
|
{
|
||||||
|
output: result ?? undefined,
|
||||||
|
properties: task.outputProperties ?? undefined,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (completedTask.status === "CANCELED") {
|
||||||
|
throw new CanceledWithTaskError(completedTask);
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -23,7 +23,11 @@ import {
|
|||||||
SourceMetadata,
|
SourceMetadata,
|
||||||
} from "@trigger.dev/internal";
|
} from "@trigger.dev/internal";
|
||||||
import { ApiClient } from "./apiClient";
|
import { ApiClient } from "./apiClient";
|
||||||
import { ResumeWithTaskError, RetryWithTaskError } from "./errors";
|
import {
|
||||||
|
CanceledWithTaskError,
|
||||||
|
ResumeWithTaskError,
|
||||||
|
RetryWithTaskError,
|
||||||
|
} from "./errors";
|
||||||
import { IO } from "./io";
|
import { IO } from "./io";
|
||||||
import { createIOWithIntegrations } from "./ioWithIntegrations";
|
import { createIOWithIntegrations } from "./ioWithIntegrations";
|
||||||
import { Job } from "./job";
|
import { Job } from "./job";
|
||||||
@@ -659,6 +663,13 @@ export class TriggerClient {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (error instanceof CanceledWithTaskError) {
|
||||||
|
return {
|
||||||
|
status: "CANCELED",
|
||||||
|
task: error.task,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (error instanceof RetryWithTaskError) {
|
if (error instanceof RetryWithTaskError) {
|
||||||
const errorWithStack = ErrorWithStackSchema.safeParse(error.cause);
|
const errorWithStack = ErrorWithStackSchema.safeParse(error.cause);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user