v4: implement onCancel callbacks (#2022)

* v4: implement onCancel callbacks

* chat example

* Add changeset

* A few improvements
This commit is contained in:
Eric Allam
2025-05-03 20:50:11 +01:00
committed by GitHub
parent 15816e9955
commit 13f750a18b
26 changed files with 575 additions and 110 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@trigger.dev/sdk": patch
---
Add onCancel lifecycle hook
@@ -97,6 +97,7 @@ export function RunIcon({ name, className, spanName }: TaskIconProps) {
case "task-hook-onResume":
case "task-hook-onComplete":
case "task-hook-cleanup":
case "task-hook-onCancel":
return <FunctionIcon className={cn(className, "text-text-dimmed")} />;
case "task-hook-onFailure":
case "task-hook-catchError":
@@ -47,29 +47,6 @@ export class CancelTaskRunService extends BaseService {
tx: this._prisma,
});
const inProgressEvents = await eventRepository.queryIncompleteEvents(
getTaskEventStoreTableForRun(taskRun),
{
runId: taskRun.friendlyId,
},
taskRun.createdAt,
taskRun.completedAt ?? undefined
);
logger.debug("Cancelling in-progress events", {
inProgressEvents: inProgressEvents.map((event) => event.id),
});
await Promise.all(
inProgressEvents.map((event) => {
return eventRepository.cancelEvent(
event,
options?.cancelledAt ?? new Date(),
options?.reason ?? "Run cancelled"
);
})
);
return {
id: result.run.id,
};
@@ -23,6 +23,7 @@ import {
TaskRunExecution,
timeout,
TriggerConfig,
UsageMeasurement,
waitUntil,
WorkerManifest,
WorkerToExecutorMessageCatalog,
@@ -232,7 +233,10 @@ async function bootstrap() {
let _execution: TaskRunExecution | undefined;
let _isRunning = false;
let _isCancelled = false;
let _tracingSDK: TracingSDK | undefined;
let _executionMeasurement: UsageMeasurement | undefined;
const cancelController = new AbortController();
const zodIpc = new ZodIpcConnection({
listenSchema: WorkerToExecutorMessageCatalog,
@@ -403,18 +407,17 @@ const zodIpc = new ZodIpcConnection({
getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000)
);
const measurement = usage.start();
_executionMeasurement = usage.start();
// This lives outside of the executor because this will eventually be moved to the controller level
const signal = execution.run.maxDuration
? timeout.abortAfterTimeout(execution.run.maxDuration)
: undefined;
const timeoutController = timeout.abortAfterTimeout(execution.run.maxDuration);
const signal = AbortSignal.any([cancelController.signal, timeoutController.signal]);
const { result } = await executor.execute(execution, metadata, traceContext, signal);
const usageSample = usage.stop(measurement);
if (_isRunning && !_isCancelled) {
const usageSample = usage.stop(_executionMeasurement);
if (_isRunning) {
return sender.send("TASK_RUN_COMPLETED", {
execution,
result: {
@@ -458,7 +461,16 @@ const zodIpc = new ZodIpcConnection({
WAIT_COMPLETED_NOTIFICATION: async () => {
await managedWorkerRuntime.completeWaitpoints([]);
},
FLUSH: async ({ timeoutInMs }, sender) => {
CANCEL: async ({ timeoutInMs }) => {
_isCancelled = true;
cancelController.abort("run cancelled");
await callCancelHooks(timeoutInMs);
if (_executionMeasurement) {
usage.stop(_executionMeasurement);
}
await flushAll(timeoutInMs);
},
FLUSH: async ({ timeoutInMs }) => {
await flushAll(timeoutInMs);
},
WAITPOINT_CREATED: async ({ wait, waitpoint }) => {
@@ -470,6 +482,18 @@ const zodIpc = new ZodIpcConnection({
},
});
async function callCancelHooks(timeoutInMs: number = 10_000) {
const now = performance.now();
try {
await Promise.race([lifecycleHooks.callOnCancelHookListeners(), setTimeout(timeoutInMs)]);
} finally {
const duration = performance.now() - now;
log(`Called cancel hooks in ${duration}ms`);
}
}
async function flushAll(timeoutInMs: number = 10_000) {
const now = performance.now();
@@ -22,6 +22,7 @@ import {
TaskRunExecution,
timeout,
TriggerConfig,
UsageMeasurement,
waitUntil,
WorkerManifest,
WorkerToExecutorMessageCatalog,
@@ -229,7 +230,10 @@ async function bootstrap() {
let _execution: TaskRunExecution | undefined;
let _isRunning = false;
let _isCancelled = false;
let _tracingSDK: TracingSDK | undefined;
let _executionMeasurement: UsageMeasurement | undefined;
const cancelController = new AbortController();
const zodIpc = new ZodIpcConnection({
listenSchema: WorkerToExecutorMessageCatalog,
@@ -398,18 +402,17 @@ const zodIpc = new ZodIpcConnection({
getNumberEnvVar("TRIGGER_RUN_METADATA_FLUSH_INTERVAL", 1000)
);
const measurement = usage.start();
_executionMeasurement = usage.start();
// This lives outside of the executor because this will eventually be moved to the controller level
const signal = execution.run.maxDuration
? timeout.abortAfterTimeout(execution.run.maxDuration)
: undefined;
const timeoutController = timeout.abortAfterTimeout(execution.run.maxDuration);
const signal = AbortSignal.any([cancelController.signal, timeoutController.signal]);
const { result } = await executor.execute(execution, metadata, traceContext, signal);
const usageSample = usage.stop(measurement);
if (_isRunning && !_isCancelled) {
const usageSample = usage.stop(_executionMeasurement);
if (_isRunning) {
return sender.send("TASK_RUN_COMPLETED", {
execution,
result: {
@@ -454,6 +457,15 @@ const zodIpc = new ZodIpcConnection({
FLUSH: async ({ timeoutInMs }, sender) => {
await flushAll(timeoutInMs);
},
CANCEL: async ({ timeoutInMs }, sender) => {
_isCancelled = true;
cancelController.abort("run cancelled");
await callCancelHooks(timeoutInMs);
if (_executionMeasurement) {
usage.stop(_executionMeasurement);
}
await flushAll(timeoutInMs);
},
WAITPOINT_CREATED: async ({ wait, waitpoint }) => {
managedWorkerRuntime.associateWaitWithWaitpoint(wait.id, waitpoint.id);
},
@@ -463,6 +475,18 @@ const zodIpc = new ZodIpcConnection({
},
});
async function callCancelHooks(timeoutInMs: number = 10_000) {
const now = performance.now();
try {
await Promise.race([lifecycleHooks.callOnCancelHookListeners(), setTimeout(timeoutInMs)]);
} finally {
const duration = performance.now() - now;
console.log(`Called cancel hooks in ${duration}ms`);
}
}
async function flushAll(timeoutInMs: number = 10_000) {
const now = performance.now();
@@ -109,9 +109,9 @@ export class TaskRunProcess {
this._isBeingCancelled = true;
try {
await this.#flush();
await this.#cancel();
} catch (err) {
console.error("Error flushing task run process", { err });
console.error("Error cancelling task run process", { err });
}
await this.kill();
@@ -120,6 +120,10 @@ export class TaskRunProcess {
async cleanup(kill = true) {
this._isPreparedForNextRun = false;
if (this._isBeingCancelled) {
return;
}
try {
await this.#flush();
} catch (err) {
@@ -224,10 +228,17 @@ export class TaskRunProcess {
await this._ipc?.sendWithAck("FLUSH", { timeoutInMs }, timeoutInMs + 1_000);
}
async #cancel(timeoutInMs: number = 30_000) {
logger.debug("sending cancel message to task run process", { pid: this.pid, timeoutInMs });
await this._ipc?.sendWithAck("CANCEL", { timeoutInMs }, timeoutInMs + 1_000);
}
async execute(
params: TaskRunProcessExecuteParams,
isWarmStart?: boolean
): Promise<TaskRunExecutionResult> {
this._isBeingCancelled = false;
this._isPreparedForNextRun = false;
this._isPreparedForNextAttempt = false;
+22
View File
@@ -16,3 +16,25 @@ export async function tryCatch<T, E = Error>(
return [error as E, null];
}
}
export type Deferred<T> = {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: any) => void;
};
export function promiseWithResolvers<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (reason?: any) => void;
const promise = new Promise<T>((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
return {
promise,
resolve,
reject,
};
}
@@ -32,4 +32,7 @@ export type {
AnyOnCleanupHookFunction,
TaskCleanupHookParams,
TaskWait,
TaskCancelHookParams,
OnCancelHookFunction,
AnyOnCancelHookFunction,
} from "./lifecycleHooks/types.js";
@@ -13,6 +13,7 @@ import {
AnyOnStartHookFunction,
AnyOnSuccessHookFunction,
AnyOnWaitHookFunction,
AnyOnCancelHookFunction,
RegisteredHookFunction,
RegisterHookFunctionParams,
TaskWait,
@@ -260,6 +261,33 @@ export class LifecycleHooksAPI {
this.#getManager().registerOnResumeHookListener(listener);
}
public registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void {
this.#getManager().registerGlobalCancelHook(hook);
}
public registerTaskCancelHook(
taskId: string,
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
): void {
this.#getManager().registerTaskCancelHook(taskId, hook);
}
public getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined {
return this.#getManager().getTaskCancelHook(taskId);
}
public getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[] {
return this.#getManager().getGlobalCancelHooks();
}
public callOnCancelHookListeners(): Promise<void> {
return this.#getManager().callOnCancelHookListeners();
}
public registerOnCancelHookListener(listener: () => Promise<void>): void {
this.#getManager().registerOnCancelHookListener(listener);
}
#getManager(): LifecycleHooksManager {
return getGlobal(API_NAME) ?? NOOP_LIFECYCLE_HOOKS_MANAGER;
}
+73 -3
View File
@@ -13,6 +13,7 @@ import {
AnyOnMiddlewareHookFunction,
AnyOnCleanupHookFunction,
TaskWait,
AnyOnCancelHookFunction,
} from "./types.js";
export class StandardLifecycleHooksManager implements LifecycleHooksManager {
@@ -37,9 +38,6 @@ export class StandardLifecycleHooksManager implements LifecycleHooksManager {
private taskCompleteHooks: Map<string, RegisteredHookFunction<AnyOnCompleteHookFunction>> =
new Map();
private globalWaitHooks: Map<string, RegisteredHookFunction<AnyOnWaitHookFunction>> = new Map();
private taskWaitHooks: Map<string, RegisteredHookFunction<AnyOnWaitHookFunction>> = new Map();
private globalResumeHooks: Map<string, RegisteredHookFunction<AnyOnResumeHookFunction>> =
new Map();
private taskResumeHooks: Map<string, RegisteredHookFunction<AnyOnResumeHookFunction>> = new Map();
@@ -59,9 +57,25 @@ export class StandardLifecycleHooksManager implements LifecycleHooksManager {
private taskCleanupHooks: Map<string, RegisteredHookFunction<AnyOnCleanupHookFunction>> =
new Map();
private globalWaitHooks: Map<string, RegisteredHookFunction<AnyOnWaitHookFunction>> = new Map();
private taskWaitHooks: Map<string, RegisteredHookFunction<AnyOnWaitHookFunction>> = new Map();
private onWaitHookListeners: ((wait: TaskWait) => Promise<void>)[] = [];
private onResumeHookListeners: ((wait: TaskWait) => Promise<void>)[] = [];
private globalCancelHooks: Map<string, RegisteredHookFunction<AnyOnCancelHookFunction>> =
new Map();
private taskCancelHooks: Map<string, RegisteredHookFunction<AnyOnCancelHookFunction>> = new Map();
private onCancelHookListeners: (() => Promise<void>)[] = [];
registerOnCancelHookListener(listener: () => Promise<void>): void {
this.onCancelHookListeners.push(listener);
}
async callOnCancelHookListeners(): Promise<void> {
await Promise.allSettled(this.onCancelHookListeners.map((listener) => listener()));
}
registerOnWaitHookListener(listener: (wait: TaskWait) => Promise<void>): void {
this.onWaitHookListeners.push(listener);
}
@@ -394,9 +408,65 @@ export class StandardLifecycleHooksManager implements LifecycleHooksManager {
getGlobalCleanupHooks(): RegisteredHookFunction<AnyOnCleanupHookFunction>[] {
return Array.from(this.globalCleanupHooks.values());
}
registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void {
const id = generateHookId(hook);
this.globalCancelHooks.set(id, {
id,
name: hook.id,
fn: hook.fn,
});
}
registerTaskCancelHook(
taskId: string,
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
): void {
const id = generateHookId(hook);
this.taskCancelHooks.set(taskId, {
id,
name: hook.id,
fn: hook.fn,
});
}
getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[] {
return Array.from(this.globalCancelHooks.values());
}
getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined {
return this.taskCancelHooks.get(taskId)?.fn;
}
}
export class NoopLifecycleHooksManager implements LifecycleHooksManager {
registerOnCancelHookListener(listener: () => Promise<void>): void {
// Noop
}
async callOnCancelHookListeners(): Promise<void> {
// Noop
}
registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void {}
registerTaskCancelHook(
taskId: string,
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
): void {
// Noop
}
getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined {
return undefined;
}
getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[] {
return [];
}
registerOnWaitHookListener(listener: (wait: TaskWait) => Promise<void>): void {
// Noop
}
+44 -10
View File
@@ -7,7 +7,7 @@ export type TaskInitHookParams<TPayload = unknown> = {
ctx: TaskRunContext;
payload: TPayload;
task: string;
signal?: AbortSignal;
signal: AbortSignal;
};
export type OnInitHookFunction<TPayload, TInitOutput extends TaskInitOutput> = (
@@ -23,7 +23,7 @@ export type TaskStartHookParams<
ctx: TaskRunContext;
payload: TPayload;
task: string;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -60,7 +60,7 @@ export type TaskWaitHookParams<
ctx: TaskRunContext;
payload: TPayload;
task: string;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -78,7 +78,7 @@ export type TaskResumeHookParams<
wait: TaskWait;
payload: TPayload;
task: string;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -96,7 +96,7 @@ export type TaskFailureHookParams<
payload: TPayload;
task: string;
error: unknown;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -115,7 +115,7 @@ export type TaskSuccessHookParams<
payload: TPayload;
task: string;
output: TOutput;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -152,7 +152,7 @@ export type TaskCompleteHookParams<
payload: TPayload;
task: string;
result: TaskCompleteResult<TOutput>;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -188,7 +188,7 @@ export type TaskCatchErrorHookParams<
retry?: RetryOptions;
retryAt?: Date;
retryDelayInMs?: number;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -203,7 +203,7 @@ export type TaskMiddlewareHookParams<TPayload = unknown> = {
ctx: TaskRunContext;
payload: TPayload;
task: string;
signal?: AbortSignal;
signal: AbortSignal;
next: () => Promise<void>;
};
@@ -220,7 +220,7 @@ export type TaskCleanupHookParams<
ctx: TaskRunContext;
payload: TPayload;
task: string;
signal?: AbortSignal;
signal: AbortSignal;
init?: TInitOutput;
};
@@ -230,6 +230,29 @@ export type OnCleanupHookFunction<TPayload, TInitOutput extends TaskInitOutput =
export type AnyOnCleanupHookFunction = OnCleanupHookFunction<unknown, TaskInitOutput>;
export type TaskCancelHookParams<
TPayload = unknown,
TRunOutput = any,
TInitOutput extends TaskInitOutput = TaskInitOutput,
> = {
ctx: TaskRunContext;
payload: TPayload;
task: string;
runPromise: Promise<TRunOutput>;
init?: TInitOutput;
signal: AbortSignal;
};
export type OnCancelHookFunction<
TPayload,
TRunOutput = any,
TInitOutput extends TaskInitOutput = TaskInitOutput,
> = (
params: TaskCancelHookParams<TPayload, TRunOutput, TInitOutput>
) => undefined | void | Promise<undefined | void>;
export type AnyOnCancelHookFunction = OnCancelHookFunction<unknown, unknown, TaskInitOutput>;
export interface LifecycleHooksManager {
registerGlobalInitHook(hook: RegisterHookFunctionParams<AnyOnInitHookFunction>): void;
registerTaskInitHook(
@@ -307,4 +330,15 @@ export interface LifecycleHooksManager {
callOnResumeHookListeners(wait: TaskWait): Promise<void>;
registerOnResumeHookListener(listener: (wait: TaskWait) => Promise<void>): void;
registerGlobalCancelHook(hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>): void;
registerTaskCancelHook(
taskId: string,
hook: RegisterHookFunctionParams<AnyOnCancelHookFunction>
): void;
getGlobalCancelHooks(): RegisteredHookFunction<AnyOnCancelHookFunction>[];
getTaskCancelHook(taskId: string): AnyOnCancelHookFunction | undefined;
registerOnCancelHookListener(listener: () => Promise<void>): void;
callOnCancelHookListeners(): Promise<void>;
}
+6
View File
@@ -243,6 +243,12 @@ export const WorkerToExecutorMessageCatalog = {
}),
callback: z.void(),
},
CANCEL: {
message: z.object({
timeoutInMs: z.number(),
}),
callback: z.void(),
},
WAITPOINT_CREATED: {
message: z.object({
version: z.literal("v1").default("v1"),
+6 -6
View File
@@ -4,8 +4,8 @@ import { TimeoutManager } from "./types.js";
const API_NAME = "timeout";
class NoopTimeoutManager implements TimeoutManager {
abortAfterTimeout(timeoutInSeconds: number): AbortSignal {
return new AbortController().signal;
abortAfterTimeout(timeoutInSeconds?: number): AbortController {
return new AbortController();
}
}
@@ -25,11 +25,11 @@ export class TimeoutAPI implements TimeoutManager {
}
public get signal(): AbortSignal | undefined {
return this.#getManagerManager().signal;
return this.#getManager().signal;
}
public abortAfterTimeout(timeoutInSeconds: number): AbortSignal {
return this.#getManagerManager().abortAfterTimeout(timeoutInSeconds);
public abortAfterTimeout(timeoutInSeconds?: number): AbortController {
return this.#getManager().abortAfterTimeout(timeoutInSeconds);
}
public setGlobalManager(manager: TimeoutManager): boolean {
@@ -40,7 +40,7 @@ export class TimeoutAPI implements TimeoutManager {
unregisterGlobal(API_NAME);
}
#getManagerManager(): TimeoutManager {
#getManager(): TimeoutManager {
return getGlobal(API_NAME) ?? NOOP_TIMEOUT_MANAGER;
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
export interface TimeoutManager {
abortAfterTimeout: (timeoutInSeconds: number) => AbortSignal;
abortAfterTimeout: (timeoutInSeconds?: number) => AbortController;
signal?: AbortSignal;
}
@@ -4,6 +4,7 @@ import { TaskRunExceededMaxDuration, TimeoutManager } from "./types.js";
export class UsageTimeoutManager implements TimeoutManager {
private _abortController: AbortController;
private _abortSignal: AbortSignal | undefined;
private _intervalId: NodeJS.Timeout | undefined;
constructor(private readonly usageManager: UsageManager) {
this._abortController = new AbortController();
@@ -13,15 +14,23 @@ export class UsageTimeoutManager implements TimeoutManager {
return this._abortSignal;
}
abortAfterTimeout(timeoutInSeconds: number): AbortSignal {
abortAfterTimeout(timeoutInSeconds?: number): AbortController {
this._abortSignal = this._abortController.signal;
if (!timeoutInSeconds) {
return this._abortController;
}
if (this._intervalId) {
clearInterval(this._intervalId);
}
// Now we need to start an interval that will measure usage and abort the signal if the usage is too high
const intervalId = setInterval(() => {
this._intervalId = setInterval(() => {
const sample = this.usageManager.sample();
if (sample) {
if (sample.cpuTime > timeoutInSeconds * 1000) {
clearInterval(intervalId);
clearInterval(this._intervalId);
this._abortController.abort(
new TaskRunExceededMaxDuration(timeoutInSeconds, sample.cpuTime / 1000)
@@ -30,6 +39,6 @@ export class UsageTimeoutManager implements TimeoutManager {
}
}, 1000);
return this._abortSignal;
return this._abortController;
}
}
+18 -8
View File
@@ -12,6 +12,7 @@ import {
OnStartHookFunction,
OnSuccessHookFunction,
OnWaitHookFunction,
OnCancelHookFunction,
} from "../lifecycleHooks/types.js";
import { RunTags } from "../schemas/api.js";
import {
@@ -88,28 +89,36 @@ export type RunFnParams<TInitOutput extends InitOutput> = Prettify<{
ctx: Context;
/** If you use the `init` function, this will be whatever you returned. */
init?: TInitOutput;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
/** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
signal: AbortSignal;
}>;
export type MiddlewareFnParams = Prettify<{
ctx: Context;
next: () => Promise<void>;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
/** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
signal: AbortSignal;
}>;
export type InitFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
/** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
signal: AbortSignal;
}>;
export type StartFnParams = Prettify<{
ctx: Context;
init?: InitOutput;
/** Abort signal that is aborted when a task run exceeds it's maxDuration. Can be used to automatically cancel downstream requests */
signal?: AbortSignal;
/** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
signal: AbortSignal;
}>;
export type CancelFnParams = Prettify<{
ctx: Context;
/** Abort signal that is aborted when a task run exceeds it's maxDuration or if the task run is cancelled. Can be used to automatically cancel downstream requests */
signal: AbortSignal;
runPromise: Promise<unknown>;
init?: InitOutput;
}>;
export type Context = TaskRunContext;
@@ -296,6 +305,7 @@ type CommonTaskOptions<
onResume?: OnResumeHookFunction<TPayload>;
onWait?: OnWaitHookFunction<TPayload>;
onComplete?: OnCompleteHookFunction<TPayload, TOutput>;
onCancel?: OnCancelHookFunction<TPayload, TOutput, TInitOutput>;
/**
* middleware allows you to run code "around" the run function. This can be useful for logging, metrics, or other cross-cutting concerns.
+2
View File
@@ -3,3 +3,5 @@
import { UsageAPI } from "./usage/api.js";
/** Entrypoint for usage API */
export const usage = UsageAPI.getInstance();
export type { UsageMeasurement, UsageSample } from "./usage/types.js";
@@ -74,7 +74,9 @@ export class DevUsageManager implements UsageManager {
const sample = measurement.sample();
this._currentMeasurements.delete(measurement.id);
if (this._currentMeasurements.has(measurement.id)) {
this._currentMeasurements.delete(measurement.id);
}
return sample;
}
+141 -33
View File
@@ -1,4 +1,4 @@
import { SpanKind } from "@opentelemetry/api";
import { Context, context, SpanKind, trace } from "@opentelemetry/api";
import { VERSION } from "../../version.js";
import { ApiError, RateLimitError } from "../apiClient/errors.js";
import { ConsoleInterceptor } from "../consoleInterceptor.js";
@@ -51,6 +51,7 @@ import {
stringifyIO,
} from "../utils/ioSerialization.js";
import { calculateNextRetryDelay } from "../utils/retries.js";
import { promiseWithResolvers } from "../../utils.js";
export type TaskExecutorOptions = {
tracingSDK: TracingSDK;
@@ -90,7 +91,7 @@ export class TaskExecutor {
execution: TaskRunExecution,
worker: ServerBackgroundWorker,
traceContext: Record<string, unknown>,
signal?: AbortSignal,
signal: AbortSignal,
isWarmStart?: boolean
): Promise<{ result: TaskRunExecutionResult }> {
const ctx = TaskRunContext.parse(execution);
@@ -120,6 +121,8 @@ export class TaskExecutor {
const result = await this._tracer.startActiveSpan(
attemptMessage,
async (span) => {
const attemptContext = context.active();
return await this._consoleInterceptor.intercept(console, async () => {
let parsedPayload: any;
let initOutput: any;
@@ -150,6 +153,26 @@ export class TaskExecutor {
await this.#callOnResumeFunctions(wait, parsedPayload, ctx, initOutput, signal);
});
const {
promise: runPromise,
resolve: runResolve,
reject: runReject,
} = promiseWithResolvers<void>();
// Make sure the run promise does not cause unhandled promise rejections
runPromise.catch(() => {});
lifecycleHooks.registerOnCancelHookListener(async () => {
await this.#callOnCancelFunctions(
runPromise,
parsedPayload,
ctx,
initOutput,
signal,
attemptContext
);
});
const executeTask = async (payload: any) => {
const [runError, output] = await tryCatch(
(async () => {
@@ -172,6 +195,8 @@ export class TaskExecutor {
);
if (runError) {
runReject(runError);
const [handleErrorError, handleErrorResult] = await tryCatch(
this.#handleError(execution, runError, payload, ctx, initOutput, signal)
);
@@ -220,6 +245,8 @@ export class TaskExecutor {
} satisfies TaskRunExecutionResult;
}
runResolve(output);
const [outputError, stringifiedOutput] = await tryCatch(stringifyIO(output));
if (outputError) {
@@ -336,7 +363,7 @@ export class TaskExecutor {
execution: TaskRunExecution,
hooks: RegisteredHookFunction<AnyOnMiddlewareHookFunction>[],
executeTask: (payload: unknown) => Promise<TaskRunExecutionResult>,
signal?: AbortSignal
signal: AbortSignal
) {
let output: any;
let executeError: unknown;
@@ -384,7 +411,7 @@ export class TaskExecutor {
return output;
}
async #callRun(payload: unknown, ctx: TaskRunContext, init: unknown, signal?: AbortSignal) {
async #callRun(payload: unknown, ctx: TaskRunContext, init: unknown, signal: AbortSignal) {
const runFn = this.task.fns.run;
if (!runFn) {
@@ -392,30 +419,29 @@ export class TaskExecutor {
}
// Create a promise that rejects when the signal aborts
const abortPromise = signal
? new Promise((_, reject) => {
signal.addEventListener("abort", () => {
const maxDuration = ctx.run.maxDuration;
reject(
new InternalError({
code: TaskRunErrorCodes.MAX_DURATION_EXCEEDED,
message: `Run exceeded maximum compute time (maxDuration) of ${maxDuration} seconds`,
})
);
});
})
: undefined;
const abortPromise = new Promise((_, reject) => {
signal.addEventListener("abort", () => {
if (typeof signal.reason === "string" && signal.reason.includes("cancel")) {
console.log("abortPromise: cancel");
return;
}
const maxDuration = ctx.run.maxDuration;
reject(
new InternalError({
code: TaskRunErrorCodes.MAX_DURATION_EXCEEDED,
message: `Run exceeded maximum compute time (maxDuration) of ${maxDuration} seconds`,
})
);
});
});
return runTimelineMetrics.measureMetric("trigger.dev/execution", "run", async () => {
return await this._tracer.startActiveSpan(
"run()",
async (span) => {
if (abortPromise) {
// Race between the run function and the abort promise
return await Promise.race([runFn(payload, { ctx, init, signal }), abortPromise]);
}
return await runFn(payload, { ctx, init, signal });
// Race between the run function and the abort promise
return await Promise.race([runFn(payload, { ctx, init, signal }), abortPromise]);
},
{
attributes: { [SemanticInternalAttributes.STYLE_ICON]: "task-fn-run" },
@@ -429,7 +455,7 @@ export class TaskExecutor {
payload: unknown,
ctx: TaskRunContext,
initOutput: TaskInitOutput,
signal?: AbortSignal
signal: AbortSignal
) {
const globalWaitHooks = lifecycleHooks.getGlobalWaitHooks();
const taskWaitHook = lifecycleHooks.getTaskWaitHook(this.task.id);
@@ -496,12 +522,94 @@ export class TaskExecutor {
);
}
async #callOnCancelFunctions(
runPromise: Promise<any>,
payload: unknown,
ctx: TaskRunContext,
initOutput: TaskInitOutput,
signal: AbortSignal,
attemptContext: Context
) {
const globalCancelHooks = lifecycleHooks.getGlobalCancelHooks();
const taskCancelHook = lifecycleHooks.getTaskCancelHook(this.task.id);
if (globalCancelHooks.length === 0 && !taskCancelHook) {
return;
}
const result = await runTimelineMetrics.measureMetric(
"trigger.dev/execution",
"onCancel",
async () => {
for (const hook of globalCancelHooks) {
const [hookError] = await tryCatch(
this._tracer.startActiveSpan(
"onCancel()",
async (span) => {
await hook.fn({
payload,
ctx,
signal,
task: this.task.id,
init: initOutput,
runPromise,
});
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onCancel",
[SemanticInternalAttributes.COLLAPSED]: true,
...this.#lifecycleHookAccessoryAttributes(hook.name),
},
},
attemptContext
)
);
if (hookError) {
throw hookError;
}
}
if (taskCancelHook) {
const [hookError] = await tryCatch(
this._tracer.startActiveSpan(
"onCancel()",
async (span) => {
await taskCancelHook({
payload,
ctx,
signal,
task: this.task.id,
init: initOutput,
runPromise,
});
},
{
attributes: {
[SemanticInternalAttributes.STYLE_ICON]: "task-hook-onCancel",
[SemanticInternalAttributes.COLLAPSED]: true,
...this.#lifecycleHookAccessoryAttributes("task"),
},
},
attemptContext
)
);
if (hookError) {
throw hookError;
}
}
}
);
}
async #callOnResumeFunctions(
wait: TaskWait,
payload: unknown,
ctx: TaskRunContext,
initOutput: TaskInitOutput,
signal?: AbortSignal
signal: AbortSignal
) {
const globalResumeHooks = lifecycleHooks.getGlobalResumeHooks();
const taskResumeHook = lifecycleHooks.getTaskResumeHook(this.task.id);
@@ -568,7 +676,7 @@ export class TaskExecutor {
);
}
async #callInitFunctions(payload: unknown, ctx: TaskRunContext, signal?: AbortSignal) {
async #callInitFunctions(payload: unknown, ctx: TaskRunContext, signal: AbortSignal) {
const globalInitHooks = lifecycleHooks.getGlobalInitHooks();
const taskInitHook = lifecycleHooks.getTaskInitHook(this.task.id);
@@ -671,7 +779,7 @@ export class TaskExecutor {
output: any,
ctx: TaskRunContext,
initOutput: any,
signal?: AbortSignal
signal: AbortSignal
) {
const globalSuccessHooks = lifecycleHooks.getGlobalSuccessHooks();
const taskSuccessHook = lifecycleHooks.getTaskSuccessHook(this.task.id);
@@ -746,7 +854,7 @@ export class TaskExecutor {
error: unknown,
ctx: TaskRunContext,
initOutput: any,
signal?: AbortSignal
signal: AbortSignal
) {
const globalFailureHooks = lifecycleHooks.getGlobalFailureHooks();
const taskFailureHook = lifecycleHooks.getTaskFailureHook(this.task.id);
@@ -832,7 +940,7 @@ export class TaskExecutor {
payload: unknown,
ctx: TaskRunContext,
initOutput: any,
signal?: AbortSignal
signal: AbortSignal
) {
const globalStartHooks = lifecycleHooks.getGlobalStartHooks();
const taskStartHook = lifecycleHooks.getTaskStartHook(this.task.id);
@@ -898,7 +1006,7 @@ export class TaskExecutor {
payload: unknown,
ctx: TaskRunContext,
initOutput: any,
signal?: AbortSignal
signal: AbortSignal
) {
await this.#callCleanupFunctions(payload, ctx, initOutput, signal);
await this.#blockForWaitUntil();
@@ -908,7 +1016,7 @@ export class TaskExecutor {
payload: unknown,
ctx: TaskRunContext,
initOutput: any,
signal?: AbortSignal
signal: AbortSignal
) {
const globalCleanupHooks = lifecycleHooks.getGlobalCleanupHooks();
const taskCleanupHook = lifecycleHooks.getTaskCleanupHook(this.task.id);
@@ -1001,7 +1109,7 @@ export class TaskExecutor {
payload: any,
ctx: TaskRunContext,
init: TaskInitOutput,
signal?: AbortSignal
signal: AbortSignal
): Promise<
| { status: "retry"; retry: TaskRunExecutionRetry; error?: unknown }
| { status: "skipped"; error?: unknown }
@@ -1191,7 +1299,7 @@ export class TaskExecutor {
result: TaskCompleteResult<unknown>,
ctx: TaskRunContext,
initOutput: any,
signal?: AbortSignal
signal: AbortSignal
) {
const globalCompleteHooks = lifecycleHooks.getGlobalCompleteHooks();
const taskCompleteHook = lifecycleHooks.getTaskCompleteHook(this.task.id);
+3 -1
View File
@@ -1905,5 +1905,7 @@ function executeTask(
engine: "V2",
};
return executor.execute(execution, worker, {}, signal);
const $signal = signal ? signal : new AbortController().signal;
return executor.execute(execution, worker, {}, $signal);
}
+14
View File
@@ -11,6 +11,7 @@ import {
type AnyOnResumeHookFunction,
type AnyOnCatchErrorHookFunction,
type AnyOnMiddlewareHookFunction,
type AnyOnCancelHookFunction,
} from "@trigger.dev/core/v3";
export type {
@@ -25,6 +26,7 @@ export type {
AnyOnResumeHookFunction,
AnyOnCatchErrorHookFunction,
AnyOnMiddlewareHookFunction,
AnyOnCancelHookFunction,
};
export function onStart(name: string, fn: AnyOnStartHookFunction): void;
@@ -131,3 +133,15 @@ export function middleware(
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
export function onCancel(name: string, fn: AnyOnCancelHookFunction): void;
export function onCancel(fn: AnyOnCancelHookFunction): void;
export function onCancel(
fnOrName: string | AnyOnCancelHookFunction,
fn?: AnyOnCancelHookFunction
): void {
lifecycleHooks.registerGlobalCancelHook({
id: typeof fnOrName === "string" ? fnOrName : fnOrName.name ? fnOrName.name : undefined,
fn: typeof fnOrName === "function" ? fnOrName : fn!,
});
}
+7
View File
@@ -42,6 +42,7 @@ import type {
AnyOnStartHookFunction,
AnyOnSuccessHookFunction,
AnyOnWaitHookFunction,
AnyOnCancelHookFunction,
AnyRunHandle,
AnyRunTypes,
AnyTask,
@@ -1637,4 +1638,10 @@ function registerTaskLifecycleHooks<
fn: params.cleanup as AnyOnCleanupHookFunction,
});
}
if (params.onCancel) {
lifecycleHooks.registerTaskCancelHook(taskId, {
fn: params.onCancel as AnyOnCancelHookFunction,
});
}
}
+2
View File
@@ -8,6 +8,7 @@ import {
onHandleError,
onCatchError,
middleware,
onCancel,
} from "./hooks.js";
import {
batchTrigger,
@@ -95,6 +96,7 @@ export const tasks = {
onComplete,
onWait,
onResume,
onCancel,
/** @deprecated Use catchError instead */
handleError: onHandleError,
catchError: onCatchError,
+54 -2
View File
@@ -1,7 +1,7 @@
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
import { ai } from "@trigger.dev/sdk/ai";
import { logger, metadata, schemaTask, wait } from "@trigger.dev/sdk/v3";
import { logger, metadata, schemaTask, tasks, wait } from "@trigger.dev/sdk/v3";
import { sql } from "@vercel/postgres";
import { streamText, TextStreamPart, tool } from "ai";
import { nanoid } from "nanoid";
@@ -110,7 +110,7 @@ export const todoChat = schemaTask({
),
userId: z.string(),
}),
run: async ({ input, userId }) => {
run: async ({ input, userId }, { signal }) => {
metadata.set("user_id", userId);
const system = `
@@ -157,6 +157,8 @@ export const todoChat = schemaTask({
const prompt = input;
const chunks: TextStreamPart<TOOLS>[] = [];
const result = streamText({
model: getModel(),
system,
@@ -174,6 +176,10 @@ export const todoChat = schemaTask({
experimental_telemetry: {
isEnabled: true,
},
abortSignal: signal,
onChunk: ({ chunk }) => {
chunks.push(chunk);
},
});
const stream = await metadata.stream("fullStream", result.fullStream);
@@ -213,3 +219,49 @@ function getModel() {
return anthropic("claude-3-5-sonnet-latest");
}
}
export const interruptibleChat = schemaTask({
id: "interruptible-chat",
description: "Chat with the AI",
schema: z.object({
prompt: z.string().describe("The prompt to chat with the AI"),
}),
run: async ({ prompt }, { signal }) => {
const chunks: TextStreamPart<{}>[] = [];
// 👇 This is a global onCancel hook, but it's inside of the run function
tasks.onCancel(async () => {
// We have access to the chunks here
logger.info("interruptible-chat: task cancelled with chunks", { chunks });
});
try {
const result = streamText({
model: getModel(),
prompt,
experimental_telemetry: {
isEnabled: true,
},
tools: {},
abortSignal: signal,
onChunk: ({ chunk }) => {
chunks.push(chunk);
},
});
const textParts = [];
for await (const part of result.textStream) {
textParts.push(part);
}
return textParts.join("");
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// streamText will throw an AbortError if the signal is aborted, so we can handle it here
} else {
throw error;
}
}
},
});
+49 -1
View File
@@ -1,4 +1,4 @@
import { batch, logger, task, timeout, wait } from "@trigger.dev/sdk";
import { batch, logger, task, tasks, timeout, wait } from "@trigger.dev/sdk";
import { setTimeout } from "timers/promises";
import { ResourceMonitor } from "../resourceMonitor.js";
@@ -207,6 +207,54 @@ export const hooksTask = task({
cleanup: async ({ ctx, payload }) => {
logger.info("Hello, world from the cleanup hook", { payload });
},
onCancel: async ({ payload }) => {
logger.info("Hello, world from the onCancel hook", { payload });
},
});
export const cancelExampleTask = task({
id: "cancel-example",
// Signal will be aborted when the task is cancelled 👇
run: async (payload: { timeoutInSeconds: number }, { signal }) => {
logger.info("Hello, world from the cancel task", {
timeoutInSeconds: payload.timeoutInSeconds,
});
// This is a global hook that will be called if the task is cancelled
tasks.onCancel(async () => {
logger.info("global task onCancel hook but inside of the run function baby!");
});
await logger.trace("timeout", async (span) => {
try {
// We pass the signal to setTimeout to abort the timeout if the task is cancelled
await setTimeout(payload.timeoutInSeconds * 1000, undefined, { signal });
} catch (error) {
// If the timeout is aborted, this error will be thrown, we can handle it here
logger.error("Timeout error", { error });
}
});
logger.info("Hello, world from the cancel task after the timeout", {
timeoutInSeconds: payload.timeoutInSeconds,
});
return {
message: "Hello, world!",
};
},
onCancel: async ({ payload, runPromise }) => {
logger.info("Hello, world from the onCancel hook", { payload });
// You can await the runPromise to get the output of the task
const output = await runPromise;
logger.info("Hello, world from the onCancel hook after the run", { payload, output });
// You can do work inside the onCancel hook, up to 30 seconds
await setTimeout(10_000);
logger.info("Hello, world from the onCancel hook after the timeout", { payload });
},
});
export const resourceMonitorTest = task({
@@ -6,6 +6,10 @@ tasks.middleware("db", ({ ctx, payload, next }) => {
return next();
});
tasks.onCancel(async ({ ctx, payload }) => {
logger.info("Hello, world from the global cancel", { ctx, payload });
});
// tasks.onSuccess(({ ctx, payload, output }) => {
// logger.info("Hello, world from the success", { ctx, payload });
// });