v3: Cancel awaited subtasks and reliable rate-limit recovery (#1200)
* v3: cancel subtasks when parent task runs are cancelled * v3: recover from server rate limiting errors in a more reliable way - Changing from sliding window to token bucket in the API rate limiter, to help smooth out traffic - Adding spans to the API Client core & SDK functions - Added waiting spans when retrying in the API Client - Retrying in the API Client now respects the x-ratelimit-reset - Retrying ApiError’s in tasks now respects the x-ratelimit-reset - Added AbortTaskRunError that when thrown will stop retries - Added idempotency keys SDK functions and automatically injecting the run ID when inside a task - Added the ability to configure ApiRequestOptions (retries only for now) globally and on specific calls - Implement the maxAttempts TaskRunOption (it wasn’t doing anything before) * Adding some docs about the request options * Fix type error * Remove context propagation through graphile jobs * Remove logger * only select a subset of task run columns * limit columns selected in batchTrigger as well * added idempotency doc * allow scoped idempotency keys, and fixed an issue with the unique index on BatchTaskRun and TaskRun * Removed old cancel task run children code
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
v3: recover from server rate limiting errors in a more reliable way
|
||||
@@ -23,8 +23,7 @@ export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialog
|
||||
<DialogContent key="cancel">
|
||||
<DialogHeader>Cancel this run?</DialogHeader>
|
||||
<DialogDescription>
|
||||
Canceling a run will stop execution. If you want to run this later you will have to replay
|
||||
the entire run with the original payload.
|
||||
Canceling a run will stop execution, along with any executing subtasks.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
} from "./components/primitives/OperatingSystemProvider";
|
||||
import { getSharedSqsEventConsumer } from "./services/events/sqsEventConsumer";
|
||||
import { singleton } from "./utils/singleton";
|
||||
import { logger } from "./services/logger.server";
|
||||
|
||||
const ABORT_DELAY = 30000;
|
||||
|
||||
@@ -186,6 +185,7 @@ export { apiRateLimiter } from "./services/apiRateLimit.server";
|
||||
export { socketIo } from "./v3/handleSocketIo.server";
|
||||
export { wss } from "./v3/handleWebsockets.server";
|
||||
export { registryProxy } from "./v3/registryProxy.server";
|
||||
export { runWithHttpContext } from "./services/httpAsyncStorage.server";
|
||||
import { eventLoopMonitor } from "./eventLoopMonitor.server";
|
||||
import { env } from "./env.server";
|
||||
|
||||
|
||||
@@ -97,8 +97,9 @@ const EnvironmentSchema = z.object({
|
||||
* @example "1000ms"
|
||||
* @example "1000s"
|
||||
*/
|
||||
API_RATE_LIMIT_WINDOW: z.string().default("60s"),
|
||||
API_RATE_LIMIT_MAX: z.coerce.number().int().default(600),
|
||||
API_RATE_LIMIT_REFILL_INTERVAL: z.string().default("10s"), // refill 250 tokens every 10 seconds
|
||||
API_RATE_LIMIT_MAX: z.coerce.number().int().default(750), // allow bursts of 750 requests
|
||||
API_RATE_LIMIT_REFILL_RATE: z.coerce.number().int().default(250), // refix 250 tokens every 10 seconds
|
||||
API_RATE_LIMIT_REQUEST_LOGS_ENABLED: z.string().default("0"),
|
||||
API_RATE_LIMIT_REJECTION_LOGS_ENABLED: z.string().default("1"),
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { SpanKind, SpanStatusCode, trace } from "@opentelemetry/api";
|
||||
import type {
|
||||
CronItem,
|
||||
CronItemOptions,
|
||||
@@ -11,20 +12,19 @@ import type {
|
||||
WorkerUtils,
|
||||
} from "graphile-worker";
|
||||
import {
|
||||
Logger as GraphileLogger,
|
||||
run as graphileRun,
|
||||
makeWorkerUtils,
|
||||
parseCronItems,
|
||||
Logger as GraphileLogger,
|
||||
} from "graphile-worker";
|
||||
import { SpanKind, trace } from "@opentelemetry/api";
|
||||
|
||||
import { flattenAttributes } from "@trigger.dev/core/v3";
|
||||
import omit from "lodash.omit";
|
||||
import { z } from "zod";
|
||||
import { $replica, PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { PgListenService } from "~/services/db/pgListen.server";
|
||||
import { workerLogger as logger } from "~/services/logger.server";
|
||||
import { flattenAttributes } from "@trigger.dev/core/v3";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
const tracer = trace.getTracer("zodWorker", "3.0.0.dp.1");
|
||||
|
||||
@@ -338,11 +338,45 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
}
|
||||
}
|
||||
|
||||
const { job, durationInMs } = await this.#addJob(
|
||||
identifier as string,
|
||||
payload,
|
||||
spec,
|
||||
options?.tx ?? this.#prisma
|
||||
const { job, durationInMs } = await tracer.startActiveSpan(
|
||||
`Enqueue ${identifier as string}`,
|
||||
{
|
||||
kind: SpanKind.PRODUCER,
|
||||
attributes: {
|
||||
"job.task_identifier": identifier as string,
|
||||
"job.payload": payload,
|
||||
"job.priority": spec.priority,
|
||||
"job.run_at": spec.runAt?.toISOString(),
|
||||
"job.jobKey": spec.jobKey,
|
||||
"job.flags": spec.flags,
|
||||
"job.max_attempts": spec.maxAttempts,
|
||||
"worker.name": this.#name,
|
||||
},
|
||||
},
|
||||
async (span) => {
|
||||
try {
|
||||
const results = await this.#addJob(
|
||||
identifier as string,
|
||||
payload,
|
||||
spec,
|
||||
options?.tx ?? this.#prisma
|
||||
);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
span.recordException(error);
|
||||
} else {
|
||||
span.recordException(new Error(String(error)));
|
||||
}
|
||||
|
||||
span.setStatus({ code: SpanStatusCode.ERROR });
|
||||
|
||||
throw error;
|
||||
} finally {
|
||||
span.end();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
logger.debug("Enqueued worker task", {
|
||||
@@ -401,6 +435,12 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
const rows = AddJobResultsSchema.safeParse(results);
|
||||
|
||||
if (!rows.success) {
|
||||
logger.debug("results returned from add_job could not be parsed", {
|
||||
identifier,
|
||||
payload,
|
||||
spec,
|
||||
});
|
||||
|
||||
throw new Error(
|
||||
`Failed to add job to queue, zod parsing error: ${JSON.stringify(rows.error)}`
|
||||
);
|
||||
@@ -422,9 +462,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
const job = AddJobResultsSchema.safeParse(result);
|
||||
|
||||
if (!job.success) {
|
||||
logger.debug("results returned from remove_job could not be parsed", {
|
||||
error: job.error.flatten(),
|
||||
result,
|
||||
logger.debug("could not remove job, job_key did not exist", {
|
||||
jobKey,
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { env } from "~/env.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { parseRequestJsonAsync } from "~/utils/parseRequestJson.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
@@ -92,18 +93,12 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
traceContext,
|
||||
});
|
||||
|
||||
const run = await service.call(
|
||||
taskId,
|
||||
authenticationResult.environment,
|
||||
{ ...body.data },
|
||||
// { ...body.data, payload: (anyBody as any).payload },
|
||||
{
|
||||
idempotencyKey: idempotencyKey ?? undefined,
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
}
|
||||
);
|
||||
const run = await service.call(taskId, authenticationResult.environment, body.data, {
|
||||
idempotencyKey: idempotencyKey ?? undefined,
|
||||
triggerVersion: triggerVersion ?? undefined,
|
||||
traceContext,
|
||||
spanParentAsLink: spanParentAsLink === 1,
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return json({ error: "Task not found" }, { status: 404 });
|
||||
@@ -113,7 +108,9 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
id: run.friendlyId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
} else if (error instanceof Error) {
|
||||
return json({ error: error.message }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -261,7 +261,7 @@ function RunActionButtons({ span }: { span: Span }) {
|
||||
|
||||
if (span.isPartial) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog key="in-progress">
|
||||
<LinkButton
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
LeadingIcon={CloudArrowDownIcon}
|
||||
@@ -290,7 +290,7 @@ function RunActionButtons({ span }: { span: Span }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog key="complete">
|
||||
<LinkButton
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
LeadingIcon={CloudArrowDownIcon}
|
||||
|
||||
@@ -106,8 +106,10 @@ export function authorizationRateLimitMiddleware({
|
||||
hashedAuthorizationValue
|
||||
);
|
||||
|
||||
const $remaining = Math.max(0, remaining); // remaining can be negative if the user has exceeded the limit, so clamp it to 0
|
||||
|
||||
res.set("x-ratelimit-limit", limit.toString());
|
||||
res.set("x-ratelimit-remaining", remaining.toString());
|
||||
res.set("x-ratelimit-remaining", $remaining.toString());
|
||||
res.set("x-ratelimit-reset", reset.toString());
|
||||
|
||||
if (success) {
|
||||
@@ -122,12 +124,12 @@ export function authorizationRateLimitMiddleware({
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
detail: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
limit,
|
||||
remaining,
|
||||
secondsUntilReset,
|
||||
error: `Rate limit exceeded ${remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
error: `Rate limit exceeded ${$remaining}/${limit} requests remaining. Retry in ${secondsUntilReset} seconds.`,
|
||||
},
|
||||
null,
|
||||
2
|
||||
@@ -138,7 +140,11 @@ export function authorizationRateLimitMiddleware({
|
||||
|
||||
export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
keyPrefix: "api",
|
||||
limiter: Ratelimit.slidingWindow(env.API_RATE_LIMIT_MAX, env.API_RATE_LIMIT_WINDOW as Duration),
|
||||
limiter: Ratelimit.tokenBucket(
|
||||
env.API_RATE_LIMIT_REFILL_RATE,
|
||||
env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
env.API_RATE_LIMIT_MAX
|
||||
),
|
||||
pathMatchers: [/^\/api/],
|
||||
// Allow /api/v1/tasks/:id/callback/:secret
|
||||
pathWhiteList: [
|
||||
@@ -152,6 +158,8 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({
|
||||
/^\/api\/v1\/sources\/http\/[^\/]+$/, // /api/v1/sources/http/$id
|
||||
/^\/api\/v1\/endpoints\/[^\/]+\/[^\/]+\/index\/[^\/]+$/, // /api/v1/endpoints/$environmentId/$endpointSlug/index/$indexHookIdentifier
|
||||
"/api/v1/timezones",
|
||||
"/api/v1/usage/ingest",
|
||||
/^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts
|
||||
],
|
||||
log: {
|
||||
rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
|
||||
export type HttpLocalStorage = {
|
||||
requestId: string;
|
||||
path: string;
|
||||
host: string;
|
||||
};
|
||||
|
||||
const httpLocalStorage = new AsyncLocalStorage<HttpLocalStorage>();
|
||||
|
||||
export type RunWithHttpContextFunction = <T>(context: HttpLocalStorage, fn: () => T) => T;
|
||||
|
||||
export function runWithHttpContext<T>(context: HttpLocalStorage, fn: () => T): T {
|
||||
return httpLocalStorage.run(context, fn);
|
||||
}
|
||||
|
||||
export function getHttpContext(): HttpLocalStorage | undefined {
|
||||
return httpLocalStorage.getStore();
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { LogLevel } from "@trigger.dev/core-backend";
|
||||
import { Logger } from "@trigger.dev/core-backend";
|
||||
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { getHttpContext } from "./httpAsyncStorage.server";
|
||||
|
||||
const currentFieldsStore = new AsyncLocalStorage<Record<string, unknown>>();
|
||||
|
||||
@@ -16,7 +17,8 @@ export const logger = new Logger(
|
||||
sensitiveDataReplacer,
|
||||
() => {
|
||||
const fields = currentFieldsStore.getStore();
|
||||
return fields ? { ...fields } : {};
|
||||
const httpContext = getHttpContext();
|
||||
return { ...fields, http: httpContext };
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -5,12 +5,18 @@ import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { ZodWorker } from "~/platform/zodWorker.server";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { MarqsConcurrencyMonitor } from "~/v3/marqs/concurrencyMonitor.server";
|
||||
import { RequeueV2Message } from "~/v3/marqs/requeueV2Message.server";
|
||||
import { reportUsageEvent } from "~/v3/openMeter.server";
|
||||
import { RequeueTaskRunService } from "~/v3/requeueTaskRun.server";
|
||||
import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server";
|
||||
import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server";
|
||||
import { PerformTaskAttemptAlertsService } from "~/v3/services/alerts/performTaskAttemptAlerts.server";
|
||||
import { PerformBulkActionService } from "~/v3/services/bulk/performBulkAction.server";
|
||||
import { CancelTaskAttemptDependenciesService } from "~/v3/services/cancelTaskAttemptDependencies.server";
|
||||
import { EnqueueDelayedRunService } from "~/v3/services/enqueueDelayedRun.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
|
||||
import { ExpireEnqueuedRunService } from "~/v3/services/expireEnqueuedRun.server";
|
||||
import { IndexDeploymentService } from "~/v3/services/indexDeployment.server";
|
||||
import { ResumeBatchRunService } from "~/v3/services/resumeBatchRun.server";
|
||||
import { ResumeTaskDependencyService } from "~/v3/services/resumeTaskDependency.server";
|
||||
@@ -44,11 +50,6 @@ 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 { RequeueV2Message } from "~/v3/marqs/requeueV2Message.server";
|
||||
import { MarqsConcurrencyMonitor } from "~/v3/marqs/concurrencyMonitor.server";
|
||||
import { reportUsageEvent } from "~/v3/openMeter.server";
|
||||
import { EnqueueDelayedRunService } from "~/v3/services/enqueueDelayedRun.server";
|
||||
import { ExpireEnqueuedRunService } from "~/v3/services/expireEnqueuedRun.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -185,6 +186,9 @@ const workerCatalog = {
|
||||
"v3.expireRun": z.object({
|
||||
runId: z.string(),
|
||||
}),
|
||||
"v3.cancelTaskAttemptDependencies": z.object({
|
||||
attemptId: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
@@ -698,6 +702,15 @@ function getWorkerQueue() {
|
||||
return await service.call(payload.runId);
|
||||
},
|
||||
},
|
||||
"v3.cancelTaskAttemptDependencies": {
|
||||
priority: 0,
|
||||
maxAttempts: 8,
|
||||
handler: async (payload, job) => {
|
||||
const service = new CancelTaskAttemptDependenciesService();
|
||||
|
||||
return await service.call(payload.attemptId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1411,7 +1411,9 @@ function filteredAttributes(attributes: Attributes, prefix: string): Attributes
|
||||
}
|
||||
|
||||
function calculateDurationFromStart(startTime: bigint, endTime: Date = new Date()) {
|
||||
return Number(BigInt(endTime.getTime() * 1_000_000) - startTime);
|
||||
const $endtime = typeof endTime === "string" ? new Date(endTime) : endTime;
|
||||
|
||||
return Number(BigInt($endtime.getTime() * 1_000_000) - startTime);
|
||||
}
|
||||
|
||||
function getNowInNanoseconds(): bigint {
|
||||
|
||||
@@ -18,6 +18,10 @@ export abstract class BaseService {
|
||||
try {
|
||||
return await fn(span);
|
||||
} catch (e) {
|
||||
if (e instanceof ServiceValidationError) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (e instanceof Error) {
|
||||
span.recordException(e);
|
||||
} else {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { BatchTriggerTaskRequestBody } from "@trigger.dev/core/v3";
|
||||
import { BatchTriggerTaskRequestBody, logger } from "@trigger.dev/core/v3";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { TriggerTaskService } from "./triggerTask.server";
|
||||
import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server";
|
||||
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
|
||||
export type BatchTriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
@@ -25,15 +26,20 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
const existingBatch = options.idempotencyKey
|
||||
? await this._prisma.batchTaskRun.findUnique({
|
||||
where: {
|
||||
runtimeEnvironmentId_idempotencyKey: {
|
||||
runtimeEnvironmentId_taskIdentifier_idempotencyKey: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey: options.idempotencyKey,
|
||||
taskIdentifier: taskId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
items: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
taskRun: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -51,9 +57,37 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
const dependentAttempt = body?.dependentAttempt
|
||||
? await this._prisma.taskRunAttempt.findUnique({
|
||||
where: { friendlyId: body.dependentAttempt },
|
||||
include: {
|
||||
taskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
dependentAttempt &&
|
||||
(isFinalAttemptStatus(dependentAttempt.status) ||
|
||||
isFinalRunStatus(dependentAttempt.taskRun.status))
|
||||
) {
|
||||
logger.debug("Dependent attempt or run is in a terminal state", {
|
||||
dependentAttempt: dependentAttempt,
|
||||
});
|
||||
|
||||
if (isFinalAttemptStatus(dependentAttempt.status)) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot batch trigger ${taskId} as the parent attempt has a status of ${dependentAttempt.status}`
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot batch trigger ${taskId} as the parent run has a status of ${dependentAttempt.taskRun.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const batch = await this._prisma.batchTaskRun.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("batch"),
|
||||
@@ -70,37 +104,44 @@ export class BatchTriggerTaskService extends BaseService {
|
||||
let index = 0;
|
||||
|
||||
for (const item of body.items) {
|
||||
const run = await triggerTaskService.call(
|
||||
taskId,
|
||||
environment,
|
||||
{
|
||||
...item,
|
||||
options: {
|
||||
...item.options,
|
||||
dependentBatch: dependentAttempt?.id ? batch.friendlyId : undefined, // Only set dependentBatch if dependentAttempt is set which means batchTriggerAndWait was called
|
||||
try {
|
||||
const run = await triggerTaskService.call(
|
||||
taskId,
|
||||
environment,
|
||||
{
|
||||
...item,
|
||||
options: {
|
||||
...item.options,
|
||||
dependentBatch: dependentAttempt?.id ? batch.friendlyId : undefined, // Only set dependentBatch if dependentAttempt is set which means batchTriggerAndWait was called
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerVersion: options.triggerVersion,
|
||||
traceContext: options.traceContext,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
batchId: batch.friendlyId,
|
||||
{
|
||||
triggerVersion: options.triggerVersion,
|
||||
traceContext: options.traceContext,
|
||||
spanParentAsLink: options.spanParentAsLink,
|
||||
batchId: batch.friendlyId,
|
||||
}
|
||||
);
|
||||
|
||||
if (run) {
|
||||
await this._prisma.batchTaskRunItem.create({
|
||||
data: {
|
||||
batchTaskRunId: batch.id,
|
||||
taskRunId: run.id,
|
||||
status: batchTaskRunItemStatusForRunStatus(run.status),
|
||||
},
|
||||
});
|
||||
|
||||
runs.push(run.friendlyId);
|
||||
}
|
||||
);
|
||||
|
||||
if (run) {
|
||||
await this._prisma.batchTaskRunItem.create({
|
||||
data: {
|
||||
batchTaskRunId: batch.id,
|
||||
taskRunId: run.id,
|
||||
status: batchTaskRunItemStatusForRunStatus(run.status),
|
||||
},
|
||||
index++;
|
||||
} catch (error) {
|
||||
logger.error("[BatchTriggerTaskService] Error triggering task", {
|
||||
taskId,
|
||||
error,
|
||||
});
|
||||
|
||||
runs.push(run.friendlyId);
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
span.setAttribute("batchId", batch.friendlyId);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { eventRepository } from "../eventRepository.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { eventRepository } from "../eventRepository.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
|
||||
import { PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.server";
|
||||
import { isCancellableRunStatus } from "../taskStatus";
|
||||
import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.server";
|
||||
|
||||
export class CancelAttemptService extends BaseService {
|
||||
public async call(
|
||||
@@ -43,6 +43,14 @@ export class CancelAttemptService extends BaseService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskRunAttempt.status === "CANCELED") {
|
||||
logger.warn("Task run attempt is already cancelled", {
|
||||
attemptId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await marqs?.acknowledgeMessage(taskRunId);
|
||||
|
||||
await this._prisma.taskRunAttempt.update({
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { CancelTaskRunService } from "./cancelTaskRun.server";
|
||||
|
||||
export class CancelTaskAttemptDependenciesService extends BaseService {
|
||||
public async call(attemptId: string) {
|
||||
const taskAttempt = await this._prisma.taskRunAttempt.findUnique({
|
||||
where: { id: attemptId },
|
||||
include: {
|
||||
dependencies: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
},
|
||||
},
|
||||
batchDependencies: {
|
||||
include: {
|
||||
runDependencies: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskAttempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskAttempt.status !== "CANCELED") {
|
||||
logger.debug("Task attempt is not cancelled, continuing anyway", {
|
||||
attemptId,
|
||||
status: taskAttempt.status,
|
||||
});
|
||||
}
|
||||
|
||||
const cancelRunService = new CancelTaskRunService();
|
||||
|
||||
logger.debug("Cancelling task attempt dependencies", {
|
||||
taskAttempt,
|
||||
dependencies: taskAttempt.dependencies,
|
||||
batchDependencies: taskAttempt.batchDependencies,
|
||||
});
|
||||
|
||||
// TaskAttempt will either have dependencies or batchDependencies
|
||||
for (const dependency of taskAttempt.dependencies) {
|
||||
await cancelRunService.call(dependency.taskRun);
|
||||
}
|
||||
|
||||
for (const batchDependency of taskAttempt.batchDependencies) {
|
||||
for (const runDependency of batchDependency.runDependencies) {
|
||||
await cancelRunService.call(runDependency.taskRun);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.cancelTaskAttemptDependencies",
|
||||
{
|
||||
attemptId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `cancelTaskAttemptDependencies:${attemptId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { devPubSub } from "../marqs/devPubSub.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { CancelAttemptService } from "./cancelAttempt.server";
|
||||
import { CANCELLABLE_ATTEMPT_STATUSES, isCancellableRunStatus } from "../taskStatus";
|
||||
import { CancelTaskAttemptDependenciesService } from "./cancelTaskAttemptDependencies.server";
|
||||
|
||||
type ExtendedTaskRun = Prisma.TaskRunGetPayload<{
|
||||
include: {
|
||||
@@ -66,6 +67,16 @@ export class CancelTaskRunService extends BaseService {
|
||||
},
|
||||
include: {
|
||||
backgroundWorker: true,
|
||||
dependencies: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
},
|
||||
},
|
||||
batchTaskRunItems: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
runtimeEnvironment: true,
|
||||
@@ -103,6 +114,8 @@ export class CancelTaskRunService extends BaseService {
|
||||
attempts: ExtendedTaskRunAttempt[]
|
||||
) {
|
||||
for (const attempt of attempts) {
|
||||
await CancelTaskAttemptDependenciesService.enqueue(attempt.id, this._prisma);
|
||||
|
||||
if (run.runtimeEnvironment.type === "DEVELOPMENT") {
|
||||
// Signal the task run attempt to stop
|
||||
await devPubSub.publish(
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CreateCheckpointService } from "./createCheckpoint.server";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server";
|
||||
import { RetryAttemptService } from "./retryAttempt.server";
|
||||
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
|
||||
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
|
||||
|
||||
@@ -59,6 +60,18 @@ export class CompleteAttemptService extends BaseService {
|
||||
});
|
||||
|
||||
// No attempt, so there's no message to ACK
|
||||
return "COMPLETED";
|
||||
}
|
||||
|
||||
if (
|
||||
isFinalAttemptStatus(taskRunAttempt.status) ||
|
||||
isFinalRunStatus(taskRunAttempt.taskRun.status)
|
||||
) {
|
||||
// We don't want to retry a task run that has already been marked as failed, cancelled, or completed
|
||||
logger.debug("[CompleteAttemptService] Attempt or run is already in a final state", {
|
||||
taskRunAttempt,
|
||||
completion,
|
||||
});
|
||||
|
||||
return "COMPLETED";
|
||||
}
|
||||
@@ -393,7 +406,12 @@ async function findAttempt(prismaClient: PrismaClientOrTransaction, friendlyId:
|
||||
include: {
|
||||
taskRun: true,
|
||||
backgroundWorkerTask: true,
|
||||
backgroundWorker: true,
|
||||
backgroundWorker: {
|
||||
select: {
|
||||
id: true,
|
||||
supportsLazyAttempts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,7 +52,15 @@ export class CreateTaskRunAttemptService extends BaseService {
|
||||
},
|
||||
lockedBy: {
|
||||
include: {
|
||||
worker: true,
|
||||
worker: {
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
supportsLazyAttempts: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
batchItems: {
|
||||
@@ -119,10 +127,6 @@ export class CreateTaskRunAttemptService extends BaseService {
|
||||
queueId: queue.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
include: {
|
||||
backgroundWorker: true,
|
||||
backgroundWorkerTask: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (setToExecuting) {
|
||||
@@ -185,6 +189,7 @@ export class CreateTaskRunAttemptService extends BaseService {
|
||||
durationMs: taskRun.usageDurationMs,
|
||||
costInCents: taskRun.costInCents,
|
||||
baseCostInCents: taskRun.baseCostInCents,
|
||||
maxAttempts: taskRun.maxAttempts ?? undefined,
|
||||
},
|
||||
queue: {
|
||||
id: queue.friendlyId,
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { stringifyIO } from "@trigger.dev/core/v3";
|
||||
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
|
||||
import { TestTaskData } from "../testTask";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { TriggerTaskService } from "./triggerTask.server";
|
||||
import { TestTaskData } from "../testTask";
|
||||
import { nextScheduledTimestamps } from "../utils/calculateNextSchedule.server";
|
||||
import { stringifyIO } from "@trigger.dev/core/v3";
|
||||
|
||||
export class TestTaskService extends BaseService {
|
||||
public async call(userId: string, data: TestTaskData) {
|
||||
|
||||
@@ -13,7 +13,9 @@ import { eventRepository } from "../eventRepository.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { uploadToObjectStore } from "../r2.server";
|
||||
import { startActiveSpan } from "../tracer.server";
|
||||
import { BaseService } from "./baseService.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
|
||||
|
||||
export type TriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
@@ -46,15 +48,16 @@ export class TriggerTaskService extends BaseService {
|
||||
const existingRun = idempotencyKey
|
||||
? await this._prisma.taskRun.findUnique({
|
||||
where: {
|
||||
runtimeEnvironmentId_idempotencyKey: {
|
||||
runtimeEnvironmentId_taskIdentifier_idempotencyKey: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
idempotencyKey,
|
||||
taskIdentifier: taskId,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (existingRun && existingRun.taskIdentifier === taskId) {
|
||||
if (existingRun) {
|
||||
span.setAttribute("runId", existingRun.friendlyId);
|
||||
|
||||
return existingRun;
|
||||
@@ -69,6 +72,81 @@ export class TriggerTaskService extends BaseService {
|
||||
environment
|
||||
);
|
||||
|
||||
const dependentAttempt = body.options?.dependentAttempt
|
||||
? await this._prisma.taskRunAttempt.findUnique({
|
||||
where: { friendlyId: body.options.dependentAttempt },
|
||||
include: {
|
||||
taskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
dependentAttempt &&
|
||||
(isFinalAttemptStatus(dependentAttempt.status) ||
|
||||
isFinalRunStatus(dependentAttempt.taskRun.status))
|
||||
) {
|
||||
logger.debug("Dependent attempt or run is in a terminal state", {
|
||||
dependentAttempt: dependentAttempt,
|
||||
});
|
||||
|
||||
if (isFinalAttemptStatus(dependentAttempt.status)) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the parent attempt has a status of ${dependentAttempt.status}`
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the parent run has a status of ${dependentAttempt.taskRun.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const dependentBatchRun = body.options?.dependentBatch
|
||||
? await this._prisma.batchTaskRun.findUnique({
|
||||
where: { friendlyId: body.options.dependentBatch },
|
||||
include: {
|
||||
dependentTaskAttempt: {
|
||||
include: {
|
||||
taskRun: {
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (
|
||||
dependentBatchRun &&
|
||||
dependentBatchRun.dependentTaskAttempt &&
|
||||
(isFinalAttemptStatus(dependentBatchRun.dependentTaskAttempt.status) ||
|
||||
isFinalRunStatus(dependentBatchRun.dependentTaskAttempt.taskRun.status))
|
||||
) {
|
||||
logger.debug("Dependent batch run task attempt or run has been canceled", {
|
||||
dependentBatchRunId: dependentBatchRun.id,
|
||||
status: dependentBatchRun.status,
|
||||
attempt: dependentBatchRun.dependentTaskAttempt,
|
||||
});
|
||||
|
||||
if (isFinalAttemptStatus(dependentBatchRun.dependentTaskAttempt.status)) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the parent attempt has a status of ${dependentBatchRun.dependentTaskAttempt.status}`
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the parent run has a status of ${dependentBatchRun.dependentTaskAttempt.taskRun.status}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return await eventRepository.traceEvent(
|
||||
taskId,
|
||||
{
|
||||
@@ -139,6 +217,7 @@ export class TriggerTaskService extends BaseService {
|
||||
isTest: body.options?.test ?? false,
|
||||
delayUntil,
|
||||
queuedAt: delayUntil ? undefined : new Date(),
|
||||
maxAttempts: body.options?.maxAttempts,
|
||||
ttl,
|
||||
},
|
||||
});
|
||||
@@ -159,32 +238,20 @@ export class TriggerTaskService extends BaseService {
|
||||
event.setAttribute("runId", taskRun.friendlyId);
|
||||
span.setAttribute("runId", taskRun.friendlyId);
|
||||
|
||||
if (body.options?.dependentAttempt) {
|
||||
const dependentAttempt = await tx.taskRunAttempt.findUnique({
|
||||
where: { friendlyId: body.options.dependentAttempt },
|
||||
if (dependentAttempt) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentAttemptId: dependentAttempt.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (dependentAttempt) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentAttemptId: dependentAttempt.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
} else if (body.options?.dependentBatch) {
|
||||
const dependentBatchRun = await tx.batchTaskRun.findUnique({
|
||||
where: { friendlyId: body.options.dependentBatch },
|
||||
} else if (dependentBatchRun) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentBatchRunId: dependentBatchRun.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (dependentBatchRun) {
|
||||
await tx.taskRunDependency.create({
|
||||
data: {
|
||||
taskRunId: taskRun.id,
|
||||
dependentBatchRunId: dependentBatchRun.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (body.options?.queue) {
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { Server as IoServer } from "socket.io";
|
||||
import type { Server as EngineServer } from "engine.io";
|
||||
import { RegistryProxy } from "~/v3/registryProxy.server";
|
||||
import { RateLimitMiddleware, apiRateLimiter } from "~/services/apiRateLimit.server";
|
||||
import { type RunWithHttpContextFunction } from "~/services/httpAsyncStorage.server";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -41,6 +43,7 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
const wss: WebSocketServer | undefined = build.entry.module.wss;
|
||||
const registryProxy: RegistryProxy | undefined = build.entry.module.registryProxy;
|
||||
const apiRateLimiter: RateLimitMiddleware = build.entry.module.apiRateLimiter;
|
||||
const runWithHttpContext: RunWithHttpContextFunction = build.entry.module.runWithHttpContext;
|
||||
|
||||
if (registryProxy && process.env.ENABLE_REGISTRY_PROXY === "true") {
|
||||
console.log(`🐳 Enabling container registry proxy to ${registryProxy.origin}`);
|
||||
@@ -70,6 +73,13 @@ if (process.env.HTTP_SERVER_DISABLED !== "true") {
|
||||
next();
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
// Generate a unique request ID for each request
|
||||
const requestId = nanoid();
|
||||
|
||||
runWithHttpContext({ requestId, path: req.url, host: req.hostname }, next);
|
||||
});
|
||||
|
||||
if (process.env.DASHBOARD_AND_API_DISABLED !== "true") {
|
||||
app.use(apiRateLimiter);
|
||||
|
||||
|
||||
@@ -255,11 +255,39 @@ export const openaiTask = task({
|
||||
});
|
||||
```
|
||||
|
||||
## Using try/catch to prevent retries
|
||||
## Preventing retries
|
||||
|
||||
### Using `AbortTaskRunError`
|
||||
|
||||
You can prevent retries by throwing an `AbortTaskRunError`. This will fail the task attempt and disable retrying.
|
||||
|
||||
```ts /trigger/myTasks.ts
|
||||
import { task, AbortTaskRunError } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const openaiTask = task({
|
||||
id: "openai-task",
|
||||
run: async (payload: { prompt: string }) => {
|
||||
//if this fails, it will throw an error and stop retrying
|
||||
const chatCompletion = await openai.chat.completions.create({
|
||||
messages: [{ role: "user", content: payload.prompt }],
|
||||
model: "gpt-3.5-turbo",
|
||||
});
|
||||
|
||||
if (chatCompletion.choices[0]?.message.content === undefined) {
|
||||
// If OpenAI returns an empty response, abort retrying
|
||||
throw new AbortTaskRunError("OpenAI call failed");
|
||||
}
|
||||
|
||||
return chatCompletion.choices[0].message.content;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Using try/catch
|
||||
|
||||
Sometimes you want to catch an error and don't want to retry the task. You can use try/catch as you normally would. In this example we fallback to using Replicate if OpenAI fails.
|
||||
|
||||
```ts /trigger/
|
||||
```ts /trigger/myTasks.ts
|
||||
import { task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const openaiTask = task({
|
||||
|
||||
+112
-1
@@ -3,4 +3,115 @@ title: "Idempotency"
|
||||
description: "An API call or operation is “idempotent” if it has the same result when called more than once."
|
||||
---
|
||||
|
||||
<Snippet file="incomplete-docs.mdx" />
|
||||
We currently support idempotency at the task level, meaning that if you trigger a task with the same `idempotencyKey` twice, the second request will not create a new task run.
|
||||
|
||||
## `idempotencyKey` option
|
||||
|
||||
You can provide an `idempotencyKey` to ensure that a task is only triggered once with the same key. This is useful if you are triggering a task within another task that might be retried:
|
||||
|
||||
```typescript
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
retry: {
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
// By default, idempotency keys generated are unique to the run, to prevent retries from duplicating child tasks
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
|
||||
// Do something else, that may throw an error and cause the task to be retried
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
You can use the `idempotencyKeys.create` SDK function to create an idempotency key before passing it to the `options` object.
|
||||
|
||||
We automatically inject the run ID when generating the idempotency key when running inside a task by default. You can turn it off by passing the `scope` option to `idempotencyKeys.create`:
|
||||
|
||||
```typescript
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
retry: {
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
// This idempotency key will be the same for all runs of this task
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" });
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
|
||||
// This is the same as the above
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey: "my-task-key" });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
If you are triggering a task from your backend code, you can use the `idempotencyKeys.create` SDK function to create an idempotency key.
|
||||
|
||||
```typescript
|
||||
import { idempotencyKeys, tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// You can also pass an array of strings to create a idempotency key
|
||||
const idempotencyKey = await idempotenceKeys.create([myUser.id, "my-task"]);
|
||||
await tasks.trigger("my-task", { some: "data" }, { idempotencyKey });
|
||||
```
|
||||
|
||||
You can also pass a string to the `idempotencyKey` option, without first creating it with `idempotencyKeys.create`.
|
||||
|
||||
```typescript
|
||||
import { myTask } from "./trigger/myTasks";
|
||||
|
||||
// You can also pass an array of strings to create a idempotency key
|
||||
await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id });
|
||||
```
|
||||
|
||||
<Note>Make sure you provide sufficiently unique keys to avoid collisions.</Note>
|
||||
|
||||
You can pass the `idempotencyKey` when calling `batchTrigger` as well:
|
||||
|
||||
```typescript
|
||||
import { tasks } from "@trigger.dev/sdk/v3";
|
||||
|
||||
await tasks.batchTrigger("my-task", [
|
||||
{
|
||||
payload: { some: "data" },
|
||||
options: { idempotencyKey: await idempotenceKeys.create(myUser.id) },
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## Payload-based idempotency
|
||||
|
||||
We don't currently support payload-based idempotency, but you can implement it yourself by hashing the payload and using the hash as the idempotency key.
|
||||
|
||||
```typescript
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// Somewhere in your code
|
||||
const idempotencyKey = await idempotencyKeys.create(hash(childPayload));
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await tasks.trigger("child-task", { some: "payload" }, { idempotencyKey });
|
||||
|
||||
// Create a hash of the payload using Node.js crypto
|
||||
// Ideally, you'd do a stable serialization of the payload before hashing, to ensure the same payload always results in the same hash
|
||||
function hash(payload: any): string {
|
||||
const hash = createHash("sha256");
|
||||
hash.update(JSON.stringify(payload));
|
||||
return hash.digest("hex");
|
||||
}
|
||||
```
|
||||
|
||||
## Important notes
|
||||
|
||||
Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs.
|
||||
|
||||
If you use the same idempotency key for triggering different tasks, the tasks will not be idempotent, and both tasks will be triggered. There's currently no way to make multiple tasks idempotent with the same key.
|
||||
|
||||
@@ -4,8 +4,8 @@ description: "Welcome to the Trigger.dev v3 documentation."
|
||||
---
|
||||
|
||||
<Warning>
|
||||
The Trigger.dev v3 Developer Preview is currently in invite-only early access. [Sign up here to
|
||||
request access](https://trigger.dev/v3-early-access).
|
||||
The Trigger.dev v3 Cloud Developer Preview is currently in invite-only early access. [Sign up here
|
||||
to request access](https://trigger.dev/v3-early-access).
|
||||
</Warning>
|
||||
|
||||
## What is Trigger.dev (v3)?
|
||||
|
||||
@@ -161,6 +161,47 @@ async function main() {
|
||||
}
|
||||
```
|
||||
|
||||
## Retries
|
||||
|
||||
The SDK will automatically retry requests that fail due to network errors or server errors. By default, the SDK will retry requests up to 3 times, with an exponential backoff delay between retries.
|
||||
|
||||
You can customize the retry behavior by passing a `requestOptions` option to the `configure` function:
|
||||
|
||||
```ts
|
||||
import { configure } from "@trigger.dev/sdk/v3";
|
||||
|
||||
configure({
|
||||
requestOptions: {
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
minTimeoutInMs: 1000,
|
||||
maxTimeoutInMs: 5000,
|
||||
factor: 1.8,
|
||||
randomize: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
All SDK functions also take a `requestOptions` parameter as the last argument, which can be used to customize the request options. You can use this to disable retries for a specific request:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
async function main() {
|
||||
const run = await runs.retrieve("run_1234", {
|
||||
retry: {
|
||||
maxAttempts: 1, // Disable retries
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
<Note>
|
||||
When running inside a task, the SDK ignores customized retry options for certain functions (e.g.,
|
||||
`task.trigger`, `task.batchTrigger`), and uses retry settings optimized for task execution.
|
||||
</Note>
|
||||
|
||||
## Auto-pagination
|
||||
|
||||
All list endpoints in the management API support auto-pagination.
|
||||
|
||||
+274
-190
@@ -148,6 +148,11 @@ export const myTask = task({
|
||||
|
||||
### Task.triggerAndWait()
|
||||
|
||||
<Warning>
|
||||
This method should only be used inside a task. If you use it outside a task, it will throw an
|
||||
error.
|
||||
</Warning>
|
||||
|
||||
This is where it gets interesting. You can trigger a task and then wait for the result. This is useful when you need to call a different task and then use the result to continue with your task.
|
||||
|
||||
<Accordion title="Don't use this in parallel, e.g. with `Promise.all()`">
|
||||
@@ -206,6 +211,11 @@ export const parentTask = task({
|
||||
|
||||
### Task.batchTriggerAndWait()
|
||||
|
||||
<Warning>
|
||||
This method should only be used inside a task. If you use it outside a task, it will throw an
|
||||
error.
|
||||
</Warning>
|
||||
|
||||
You can batch trigger a task and wait for all the results. This is useful for the fan-out pattern, where you need to call a task multiple times and then wait for all the results to continue with your task.
|
||||
|
||||
<Accordion title="Don't use this in parallel, e.g. with `Promise.all()`">
|
||||
@@ -408,134 +418,233 @@ export async function POST(request: Request) {
|
||||
this way as it will block the request until the task is complete.
|
||||
</Note>
|
||||
|
||||
### runs.retrieve()
|
||||
## Options
|
||||
|
||||
You can retrieve a run by its handle using the `runs.retrieve()` function.
|
||||
All of the above functions accept an options object:
|
||||
|
||||
<CodeGroup>
|
||||
```ts
|
||||
await myTask.trigger({ some: "data" }, { delay: "1h", ttl: "1h" });
|
||||
await myTask.batchTrigger([{ payload: { some: "data" }, options: { delay: "1h" } }]);
|
||||
```
|
||||
|
||||
```ts Next.js API route
|
||||
import { tasks, runs } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
// 👆 **type-only** import
|
||||
The following options are available:
|
||||
|
||||
### `delay`
|
||||
|
||||
When you want to trigger a task now, but have it run at a later time, you can use the `delay` option:
|
||||
|
||||
```ts
|
||||
// Delay the task run by 1 hour
|
||||
await myTask.trigger({ some: "data" }, { delay: "1h" });
|
||||
// Delay the task run by 88 seconds
|
||||
await myTask.trigger({ some: "data" }, { delay: "88s" });
|
||||
// Delay the task run by 1 hour and 52 minutes and 18 seconds
|
||||
await myTask.trigger({ some: "data" }, { delay: "1h52m18s" });
|
||||
// Delay until a specific time
|
||||
await myTask.trigger({ some: "data" }, { delay: "2024-12-01T00:00:00" });
|
||||
// Delay using a Date object
|
||||
await myTask.trigger({ some: "data" }, { delay: new Date(Date.now() + 1000 * 60 * 60) });
|
||||
```
|
||||
|
||||
Runs that are delayed and have not been enqueued yet will display in the dashboard with a "Delayed" status:
|
||||
|
||||

|
||||
|
||||
<Note>
|
||||
Delayed runs will be enqueued at the time specified, and will run as soon as possible after that
|
||||
time, just as a normally triggered run would.
|
||||
</Note>
|
||||
|
||||
You can cancel a delayed run using the `runs.cancel` SDK function:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
await runs.cancel("run_1234");
|
||||
```
|
||||
|
||||
You can also reschedule a delayed run using the `runs.reschedule` SDK function:
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
|
||||
// The delay option here takes the same format as the trigger delay option
|
||||
await runs.reschedule("run_1234", { delay: "1h" });
|
||||
```
|
||||
|
||||
The `delay` option is also available when using `batchTrigger`:
|
||||
|
||||
```ts
|
||||
await myTask.batchTrigger([{ payload: { some: "data" }, options: { delay: "1h" } }]);
|
||||
```
|
||||
|
||||
### `ttl`
|
||||
|
||||
You can set a TTL (time to live) when triggering a task, which will automatically expire the run if it hasn't started within the specified time. This is useful for ensuring that a run doesn't get stuck in the queue for too long.
|
||||
|
||||
<Note>
|
||||
All runs in development have a default `ttl` of 10 minutes. You can disable this by setting the
|
||||
`ttl` option.
|
||||
</Note>
|
||||
|
||||
```ts
|
||||
import { myTask } from "./trigger/myTasks";
|
||||
|
||||
// Expire the run if it hasn't started within 1 hour
|
||||
await myTask.trigger({ some: "data" }, { ttl: "1h" });
|
||||
|
||||
// If you specify a number, it will be treated as seconds
|
||||
await myTask.trigger({ some: "data" }, { ttl: 3600 }); // 1 hour
|
||||
```
|
||||
|
||||
When a run is expired, it will be marked as "Expired" in the dashboard:
|
||||
|
||||

|
||||
|
||||
When you use both `delay` and `ttl`, the TTL will start counting down from the time the run is enqueued, not from the time the run is triggered.
|
||||
|
||||
So for example, when using the following code:
|
||||
|
||||
```ts
|
||||
await myTask.trigger({ some: "data" }, { delay: "10m", ttl: "1h" });
|
||||
```
|
||||
|
||||
The timeline would look like this:
|
||||
|
||||
1. The run is created at 12:00:00
|
||||
2. The run is enqueued at 12:10:00
|
||||
3. The TTL starts counting down from 12:10:00
|
||||
4. If the run hasn't started by 13:10:00, it will be expired
|
||||
|
||||
For this reason, the `ttl` option only accepts durations and not absolute timestamps.
|
||||
|
||||
### `idempotencyKey`
|
||||
|
||||
You can provide an `idempotencyKey` to ensure that a task is only triggered once with the same key. This is useful if you are triggering a task within another task that might be retried:
|
||||
|
||||
```typescript
|
||||
import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
retry: {
|
||||
maxAttempts: 4,
|
||||
},
|
||||
run: async (payload: any) => {
|
||||
// By default, idempotency keys generated are unique to the run, to prevent retries from duplicating child tasks
|
||||
const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
|
||||
// childTask will only be triggered once with the same idempotency key
|
||||
await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
|
||||
// Do something else, that may throw an error and cause the task to be retried
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
For more information, see our [Idempotency](/v3/idempotency) documentation.
|
||||
|
||||
### `queue`
|
||||
|
||||
When you trigger a task you can override the concurrency limit. This is really useful if you sometimes have high priority runs.
|
||||
|
||||
The task:
|
||||
|
||||
```ts /trigger/override-concurrency.ts
|
||||
const generatePullRequest = task({
|
||||
id: "generate-pull-request",
|
||||
queue: {
|
||||
//normally when triggering this task it will be limited to 1 run at a time
|
||||
concurrencyLimit: 1,
|
||||
},
|
||||
run: async (payload) => {
|
||||
//todo generate a PR using OpenAI
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Triggering from your backend and overriding the concurrency:
|
||||
|
||||
```ts app/api/push/route.ts
|
||||
import { generatePullRequest } from "~/trigger/override-concurrency";
|
||||
|
||||
//app/email/route.ts
|
||||
export async function POST(request: Request) {
|
||||
//get the JSON from the request
|
||||
const data = await request.json();
|
||||
|
||||
// Pass the task type to `trigger()` as a generic argument, giving you full type checking
|
||||
const handle = await tasks.trigger<typeof emailSequence>("email-sequence", {
|
||||
to: data.email,
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
const run = await runs.retrieve(handle);
|
||||
|
||||
// run.output will be correctly typed as the return value of the task
|
||||
return Response.json(run.output);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### runs.poll()
|
||||
|
||||
You can poll a run by its handle using the `runs.poll()` function.
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```ts Next.js API route
|
||||
import { tasks, runs } from "@trigger.dev/sdk/v3";
|
||||
import type { emailSequence } from "~/trigger/emails";
|
||||
// 👆 **type-only** import
|
||||
|
||||
//app/email/route.ts
|
||||
export async function POST(request: Request) {
|
||||
//get the JSON from the request
|
||||
const data = await request.json();
|
||||
|
||||
// Pass the task type to `trigger()` as a generic argument, giving you full type checking
|
||||
const handle = await tasks.trigger<typeof emailSequence>("email-sequence", {
|
||||
to: data.email,
|
||||
name: data.name,
|
||||
});
|
||||
|
||||
// Poll the run until it's complete
|
||||
const run = await runs.poll(handle, { pollIntervalMs: 5000 });
|
||||
|
||||
// run.output will be correctly typed as the return value of the task
|
||||
return Response.json(run.output);
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
## Next.js Server Actions
|
||||
|
||||
Server Actions allow you to call your backend code without creating API routes. This is very useful for triggering tasks but you need to be careful you don't accidentally bundle the Trigger.dev SDK into your frontend code.
|
||||
|
||||
If you see an error like this then you've bundled `@trigger.dev/sdk/v3` into your frontend code:
|
||||
|
||||
```bash
|
||||
Module build failed: UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins (Unhandled scheme).
|
||||
Module build failed: UnhandledSchemeError: Reading from "node:process" is not handled by plugins (Unhandled scheme).
|
||||
Webpack supports "data:" and "file:" URIs by default.
|
||||
You may need an additional plugin to handle "node:" URIs.
|
||||
```
|
||||
|
||||
When you use server actions that use `@trigger.dev/sdk/v3`:
|
||||
|
||||
- The file can't have any React components in it.
|
||||
- The file should have `"use server"` on the first line.
|
||||
|
||||
Here's an example of how to do it with a component that calls the server action and the actions file:
|
||||
|
||||
<CodeGroup>
|
||||
|
||||
```tsx app/page.tsx
|
||||
"use client";
|
||||
|
||||
import { create } from "@/app/actions";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const handle = await create();
|
||||
console.log(handle);
|
||||
}}
|
||||
>
|
||||
Create a thing
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```tsx app/actions.ts
|
||||
"use server";
|
||||
|
||||
import { createAvatar } from "@/trigger/create-avatar";
|
||||
|
||||
export async function create() {
|
||||
try {
|
||||
const handle = await createAvatar.trigger({
|
||||
userImage: "http://...",
|
||||
if (data.branch === "main") {
|
||||
//trigger the task, with a different queue
|
||||
const handle = await generatePullRequest.trigger(data, {
|
||||
queue: {
|
||||
//the "main-branch" queue will have a concurrency limit of 10
|
||||
//this triggered run will use that queue
|
||||
name: "main-branch",
|
||||
concurrencyLimit: 10,
|
||||
},
|
||||
});
|
||||
|
||||
return { handle };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
error: "something went wrong",
|
||||
};
|
||||
return Response.json(handle);
|
||||
} else {
|
||||
//triggered with the default (concurrency of 1)
|
||||
const handle = await generatePullRequest.trigger(data);
|
||||
return Response.json(handle);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
### `concurrencyKey`
|
||||
|
||||
If you're building an application where you want to run tasks for your users, you might want a separate queue for each of your users. (It doesn't have to be users, it can be any entity you want to separately limit the concurrency for.)
|
||||
|
||||
You can do this by using `concurrencyKey`. It creates a separate queue for each value of the key.
|
||||
|
||||
Your backend code:
|
||||
|
||||
```ts app/api/pr/route.ts
|
||||
import { generatePullRequest } from "~/trigger/override-concurrency";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const data = await request.json();
|
||||
|
||||
if (data.isFreeUser) {
|
||||
//free users can only have 1 PR generated at a time
|
||||
const handle = await generatePullRequest.trigger(data, {
|
||||
queue: {
|
||||
//every free user gets a queue with a concurrency limit of 1
|
||||
name: "free-users",
|
||||
concurrencyLimit: 1,
|
||||
},
|
||||
concurrencyKey: data.userId,
|
||||
});
|
||||
|
||||
//return a success response with the handle
|
||||
return Response.json(handle);
|
||||
} else {
|
||||
//trigger the task, with a different queue
|
||||
const handle = await generatePullRequest.trigger(data, {
|
||||
queue: {
|
||||
//every paid user gets a queue with a concurrency limit of 10
|
||||
name: "paid-users",
|
||||
concurrencyLimit: 10,
|
||||
},
|
||||
concurrencyKey: data.userId,
|
||||
});
|
||||
|
||||
//return a success response with the handle
|
||||
return Response.json(handle);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `maxAttempts`
|
||||
|
||||
You can set the maximum number of attempts for a task run. If the run fails, it will be retried up to the number of attempts you specify.
|
||||
|
||||
```ts
|
||||
await myTask.trigger({ some: "data" }, { maxAttempts: 3 });
|
||||
await myTask.trigger({ some: "data" }, { maxAttempts: 1 }); // no retries
|
||||
```
|
||||
|
||||
This will override the `retry.maxAttempts` value set in the task definition.
|
||||
|
||||
## Large Payloads
|
||||
|
||||
@@ -620,93 +729,68 @@ export const myTask = task({
|
||||
|
||||
When using `batchTrigger` or `batchTriggerAndWait`, the total size of all payloads cannot exceed 10MB. This means if you are doing a batch of 100 runs, each payload should be less than 100KB.
|
||||
|
||||
## Delayed runs
|
||||
## Next.js Server Actions
|
||||
|
||||
When you want to trigger a task now, but have it run at a later time, you can use the `delay` option:
|
||||
Server Actions allow you to call your backend code without creating API routes. This is very useful for triggering tasks but you need to be careful you don't accidentally bundle the Trigger.dev SDK into your frontend code.
|
||||
|
||||
```ts
|
||||
// Delay the task run by 1 hour
|
||||
await myTask.trigger({ some: "data" }, { delay: "1h" });
|
||||
// Delay the task run by 88 seconds
|
||||
await myTask.trigger({ some: "data" }, { delay: "88s" });
|
||||
// Delay the task run by 1 hour and 52 minutes and 18 seconds
|
||||
await myTask.trigger({ some: "data" }, { delay: "1h52m18s" });
|
||||
// Delay until a specific time
|
||||
await myTask.trigger({ some: "data" }, { delay: "2024-12-01T00:00:00" });
|
||||
// Delay using a Date object
|
||||
await myTask.trigger({ some: "data" }, { delay: new Date(Date.now() + 1000 * 60 * 60) });
|
||||
If you see an error like this then you've bundled `@trigger.dev/sdk/v3` into your frontend code:
|
||||
|
||||
```bash
|
||||
Module build failed: UnhandledSchemeError: Reading from "node:crypto" is not handled by plugins (Unhandled scheme).
|
||||
Module build failed: UnhandledSchemeError: Reading from "node:process" is not handled by plugins (Unhandled scheme).
|
||||
Webpack supports "data:" and "file:" URIs by default.
|
||||
You may need an additional plugin to handle "node:" URIs.
|
||||
```
|
||||
|
||||
Runs that are delayed and have not been enqueued yet will display in the dashboard with a "Delayed" status:
|
||||
When you use server actions that use `@trigger.dev/sdk/v3`:
|
||||
|
||||

|
||||
- The file can't have any React components in it.
|
||||
- The file should have `"use server"` on the first line.
|
||||
|
||||
<Note>
|
||||
Delayed runs will be enqueued at the time specified, and will run as soon as possible after that
|
||||
time, just as a normally triggered run would.
|
||||
</Note>
|
||||
Here's an example of how to do it with a component that calls the server action and the actions file:
|
||||
|
||||
You can cancel a delayed run using the `runs.cancel` SDK function:
|
||||
<CodeGroup>
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
```tsx app/page.tsx
|
||||
"use client";
|
||||
|
||||
await runs.cancel("run_1234");
|
||||
import { create } from "@/app/actions";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="flex min-h-screen flex-col items-center justify-between p-24">
|
||||
<button
|
||||
onClick={async () => {
|
||||
const handle = await create();
|
||||
console.log(handle);
|
||||
}}
|
||||
>
|
||||
Create a thing
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
You can also reschedule a delayed run using the `runs.reschedule` SDK function:
|
||||
```tsx app/actions.ts
|
||||
"use server";
|
||||
|
||||
```ts
|
||||
import { runs } from "@trigger.dev/sdk/v3";
|
||||
import { createAvatar } from "@/trigger/create-avatar";
|
||||
|
||||
// The delay option here takes the same format as the trigger delay option
|
||||
await runs.reschedule("run_1234", { delay: "1h" });
|
||||
export async function create() {
|
||||
try {
|
||||
const handle = await createAvatar.trigger({
|
||||
userImage: "http://...",
|
||||
});
|
||||
|
||||
return { handle };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
error: "something went wrong",
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `delay` option is also available when using `batchTrigger`:
|
||||
|
||||
```ts
|
||||
await myTask.batchTrigger([{ payload: { some: "data" }, options: { delay: "1h" } }]);
|
||||
```
|
||||
|
||||
## TTL
|
||||
|
||||
You can set a TTL (time to live) when triggering a task, which will automatically expire the run if it hasn't started within the specified time. This is useful for ensuring that a run doesn't get stuck in the queue for too long.
|
||||
|
||||
<Note>
|
||||
All runs in development have a default `ttl` of 10 minutes. You can disable this by setting the
|
||||
`ttl` option.
|
||||
</Note>
|
||||
|
||||
```ts
|
||||
import { myTask } from "./trigger/myTasks";
|
||||
|
||||
// Expire the run if it hasn't started within 1 hour
|
||||
await myTask.trigger({ some: "data" }, { ttl: "1h" });
|
||||
|
||||
// If you specify a number, it will be treated as seconds
|
||||
await myTask.trigger({ some: "data" }, { ttl: 3600 }); // 1 hour
|
||||
```
|
||||
|
||||
When a run is expired, it will be marked as "Expired" in the dashboard:
|
||||
|
||||

|
||||
|
||||
### Delayed runs and TTL
|
||||
|
||||
When you use both `delay` and `ttl`, the TTL will start counting down from the time the run is enqueued, not from the time the run is triggered.
|
||||
|
||||
So for example, when using the following code:
|
||||
|
||||
```ts
|
||||
await myTask.trigger({ some: "data" }, { delay: "10m", ttl: "1h" });
|
||||
```
|
||||
|
||||
The timeline would look like this:
|
||||
|
||||
1. The run is created at 12:00:00
|
||||
2. The run is enqueued at 12:10:00
|
||||
3. The TTL starts counting down from 12:10:00
|
||||
4. If the run hasn't started by 13:10:00, it will be expired
|
||||
|
||||
For this reason, the `ttl` option only accepts durations and not absolute timestamps.
|
||||
</CodeGroup>
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import { fromZodError } from "zod-validation-error";
|
||||
import { ApiConnectionError, ApiError } from "./errors";
|
||||
import { RetryOptions } from "../schemas";
|
||||
import { calculateNextRetryDelay } from "../utils/retries";
|
||||
import { ApiConnectionError, ApiError } from "./errors";
|
||||
|
||||
import { Attributes, Span } from "@opentelemetry/api";
|
||||
import { SemanticInternalAttributes } from "../semanticInternalAttributes";
|
||||
import { TriggerTracer } from "../tracer";
|
||||
import { accessoryAttributes } from "../utils/styleAttributes";
|
||||
import {
|
||||
CursorPage,
|
||||
CursorPageParams,
|
||||
@@ -23,6 +27,29 @@ export const defaultRetryOptions = {
|
||||
|
||||
export type ZodFetchOptions = {
|
||||
retry?: RetryOptions;
|
||||
tracer?: TriggerTracer;
|
||||
name?: string;
|
||||
attributes?: Attributes;
|
||||
icon?: string;
|
||||
};
|
||||
|
||||
export type ApiRequestOptions = Pick<ZodFetchOptions, "retry">;
|
||||
type KeysEnum<T> = { [P in keyof Required<T>]: true };
|
||||
|
||||
// This is required so that we can determine if a given object matches the ApiRequestOptions
|
||||
// type at runtime. While this requires duplication, it is enforced by the TypeScript
|
||||
// compiler such that any missing / extraneous keys will cause an error.
|
||||
const requestOptionsKeys: KeysEnum<ApiRequestOptions> = {
|
||||
retry: true,
|
||||
};
|
||||
|
||||
export const isRequestOptions = (obj: unknown): obj is ApiRequestOptions => {
|
||||
return (
|
||||
typeof obj === "object" &&
|
||||
obj !== null &&
|
||||
!isEmptyObj(obj) &&
|
||||
Object.keys(obj).every((k) => hasOwn(requestOptionsKeys, k))
|
||||
);
|
||||
};
|
||||
|
||||
interface FetchCursorPageParams extends CursorPageParams {
|
||||
@@ -120,17 +147,58 @@ type ZodFetchResult<T> = {
|
||||
|
||||
type PromiseOrValue<T> = T | Promise<T>;
|
||||
|
||||
async function traceZodFetch<T>(
|
||||
params: {
|
||||
url: string;
|
||||
requestInit?: RequestInit;
|
||||
options?: ZodFetchOptions;
|
||||
},
|
||||
callback: (span?: Span) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (!params.options?.tracer) {
|
||||
return callback();
|
||||
}
|
||||
|
||||
const url = new URL(params.url);
|
||||
const method = params.requestInit?.method ?? "GET";
|
||||
const name = params.options.name ?? `${method} ${url.pathname}`;
|
||||
|
||||
return await params.options.tracer.startActiveSpan(
|
||||
name,
|
||||
async (span) => {
|
||||
return await callback(span);
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: params.options?.icon ?? "api",
|
||||
...params.options.attributes,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function _doZodFetch<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
schema: TResponseBodySchema,
|
||||
url: string,
|
||||
requestInit?: PromiseOrValue<RequestInit>,
|
||||
options?: ZodFetchOptions
|
||||
): Promise<ZodFetchResult<z.output<TResponseBodySchema>>> {
|
||||
const $requestInit = await requestInit;
|
||||
|
||||
return traceZodFetch({ url, requestInit: $requestInit, options }, async (span) => {
|
||||
return await _doZodFetchWithRetries(schema, url, $requestInit, options);
|
||||
});
|
||||
}
|
||||
|
||||
async function _doZodFetchWithRetries<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
schema: TResponseBodySchema,
|
||||
url: string,
|
||||
requestInit?: RequestInit,
|
||||
options?: ZodFetchOptions,
|
||||
attempt = 1
|
||||
): Promise<ZodFetchResult<z.output<TResponseBodySchema>>> {
|
||||
try {
|
||||
const $requestInit = await requestInit;
|
||||
|
||||
const response = await fetch(url, requestInitWithCache($requestInit));
|
||||
const response = await fetch(url, requestInitWithCache(requestInit));
|
||||
|
||||
const responseHeaders = createResponseHeaders(response.headers);
|
||||
|
||||
@@ -138,9 +206,9 @@ async function _doZodFetch<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
const retryResult = shouldRetry(response, attempt, options?.retry);
|
||||
|
||||
if (retryResult.retry) {
|
||||
await new Promise((resolve) => setTimeout(resolve, retryResult.delay));
|
||||
await waitForRetry(url, attempt + 1, retryResult.delay, options, requestInit, response);
|
||||
|
||||
return await _doZodFetch(schema, url, requestInit, options, attempt + 1);
|
||||
return await _doZodFetchWithRetries(schema, url, requestInit, options, attempt + 1);
|
||||
} else {
|
||||
const errText = await response.text().catch((e) => castToError(e).message);
|
||||
const errJSON = safeJsonParse(errText);
|
||||
@@ -169,9 +237,9 @@ async function _doZodFetch<TResponseBodySchema extends z.ZodTypeAny>(
|
||||
const delay = calculateNextRetryDelay(retry, attempt);
|
||||
|
||||
if (delay) {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
await waitForRetry(url, attempt + 1, delay, options, requestInit);
|
||||
|
||||
return await _doZodFetch(schema, url, requestInit, options, attempt + 1);
|
||||
return await _doZodFetchWithRetries(schema, url, requestInit, options, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -224,7 +292,27 @@ function shouldRetry(
|
||||
if (response.status === 409) return shouldRetryForOptions();
|
||||
|
||||
// Retry on rate limits.
|
||||
if (response.status === 429) return shouldRetryForOptions();
|
||||
if (response.status === 429) {
|
||||
if (
|
||||
attempt >= (typeof retryOptions?.maxAttempts === "number" ? retryOptions?.maxAttempts : 3)
|
||||
) {
|
||||
return { retry: false };
|
||||
}
|
||||
|
||||
// x-ratelimit-reset is the unix timestamp in milliseconds when the rate limit will reset.
|
||||
const resetAtUnixEpochMs = response.headers.get("x-ratelimit-reset");
|
||||
|
||||
if (resetAtUnixEpochMs) {
|
||||
const resetAtUnixEpoch = parseInt(resetAtUnixEpochMs, 10);
|
||||
const delay = resetAtUnixEpoch - Date.now() + Math.floor(Math.random() * 1000);
|
||||
|
||||
if (delay > 0) {
|
||||
return { retry: true, delay };
|
||||
}
|
||||
}
|
||||
|
||||
return shouldRetryForOptions();
|
||||
}
|
||||
|
||||
// Retry internal errors.
|
||||
if (response.status >= 500) return shouldRetryForOptions();
|
||||
@@ -423,3 +511,51 @@ export class OffsetLimitPagePromise<TItemSchema extends z.ZodTypeAny>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForRetry(
|
||||
url: string,
|
||||
attempt: number,
|
||||
delay: number,
|
||||
options?: ZodFetchOptions,
|
||||
requestInit?: RequestInit,
|
||||
response?: Response
|
||||
): Promise<void> {
|
||||
if (options?.tracer) {
|
||||
const method = requestInit?.method ?? "GET";
|
||||
|
||||
return options.tracer.startActiveSpan(
|
||||
response ? `wait after ${response.status}` : `wait after error`,
|
||||
async (span) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
},
|
||||
{
|
||||
attributes: {
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "wait",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: `retrying ${options?.name ?? method.toUpperCase()} in ${delay}ms`,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/a/34491287
|
||||
export function isEmptyObj(obj: Object | null | undefined): boolean {
|
||||
if (!obj) return true;
|
||||
for (const _k in obj) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
// https://eslint.org/docs/latest/rules/no-prototype-builtins
|
||||
export function hasOwn(obj: Object, key: string): boolean {
|
||||
return Object.prototype.hasOwnProperty.call(obj, key);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export class ApiError extends Error {
|
||||
headers: APIHeaders | undefined
|
||||
) {
|
||||
super(`${ApiError.makeMessage(status, error, message)}`);
|
||||
this.name = "TriggerApiError";
|
||||
this.status = status;
|
||||
this.headers = headers;
|
||||
|
||||
@@ -132,6 +133,22 @@ export class UnprocessableEntityError extends ApiError {
|
||||
|
||||
export class RateLimitError extends ApiError {
|
||||
override readonly status: 429 = 429;
|
||||
|
||||
get millisecondsUntilReset(): number | undefined {
|
||||
// x-ratelimit-reset is the unix timestamp in milliseconds when the rate limit will reset.
|
||||
const resetAtUnixEpochMs = (this.headers ?? {})["x-ratelimit-reset"];
|
||||
|
||||
if (typeof resetAtUnixEpochMs === "string") {
|
||||
const resetAtUnixEpoch = parseInt(resetAtUnixEpochMs, 10);
|
||||
|
||||
if (isNaN(resetAtUnixEpoch)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add between 0 and 2000ms to the reset time to add jitter
|
||||
return Math.max(resetAtUnixEpoch - Date.now() + Math.floor(Math.random() * 2000), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class InternalServerError extends ApiError {}
|
||||
|
||||
@@ -26,8 +26,10 @@ import {
|
||||
} from "../schemas";
|
||||
import { taskContext } from "../task-context-api";
|
||||
import {
|
||||
ApiRequestOptions,
|
||||
CursorPagePromise,
|
||||
ZodFetchOptions,
|
||||
isRequestOptions,
|
||||
zodfetch,
|
||||
zodfetchCursorPage,
|
||||
zodfetchOffsetLimitPage,
|
||||
@@ -51,7 +53,7 @@ export type TriggerOptions = {
|
||||
spanParentAsLink?: boolean;
|
||||
};
|
||||
|
||||
const zodFetchOptions: ZodFetchOptions = {
|
||||
const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = {
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
minTimeoutInMs: 1000,
|
||||
@@ -61,20 +63,29 @@ const zodFetchOptions: ZodFetchOptions = {
|
||||
},
|
||||
};
|
||||
|
||||
export type { ApiRequestOptions };
|
||||
export { isRequestOptions };
|
||||
|
||||
/**
|
||||
* Trigger.dev v3 API client
|
||||
*/
|
||||
export class ApiClient {
|
||||
private readonly baseUrl: string;
|
||||
private readonly defaultRequestOptions: ZodFetchOptions;
|
||||
|
||||
constructor(
|
||||
baseUrl: string,
|
||||
private readonly accessToken: string
|
||||
private readonly accessToken: string,
|
||||
requestOptions: ApiRequestOptions = {}
|
||||
) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
this.defaultRequestOptions = mergeRequestOptions(DEFAULT_ZOD_FETCH_OPTIONS, requestOptions);
|
||||
}
|
||||
|
||||
async getRunResult(runId: string): Promise<TaskRunExecutionResult | undefined> {
|
||||
async getRunResult(
|
||||
runId: string,
|
||||
requestOptions?: ZodFetchOptions
|
||||
): Promise<TaskRunExecutionResult | undefined> {
|
||||
try {
|
||||
return await zodfetch(
|
||||
TaskRunExecutionResult,
|
||||
@@ -83,7 +94,7 @@ export class ApiClient {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -96,7 +107,10 @@ export class ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async getBatchResults(batchId: string): Promise<BatchTaskRunExecutionResult | undefined> {
|
||||
async getBatchResults(
|
||||
batchId: string,
|
||||
requestOptions?: ZodFetchOptions
|
||||
): Promise<BatchTaskRunExecutionResult | undefined> {
|
||||
return await zodfetch(
|
||||
BatchTaskRunExecutionResult,
|
||||
`${this.baseUrl}/api/v1/batches/${batchId}/results`,
|
||||
@@ -104,11 +118,16 @@ export class ApiClient {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
triggerTask(taskId: string, body: TriggerTaskRequestBody, options?: TriggerOptions) {
|
||||
triggerTask(
|
||||
taskId: string,
|
||||
body: TriggerTaskRequestBody,
|
||||
options?: TriggerOptions,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
const encodedTaskId = encodeURIComponent(taskId);
|
||||
|
||||
return zodfetch(
|
||||
@@ -119,11 +138,16 @@ export class ApiClient {
|
||||
headers: this.#getHeaders(options?.spanParentAsLink ?? false),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
batchTriggerTask(taskId: string, body: BatchTriggerTaskRequestBody, options?: TriggerOptions) {
|
||||
batchTriggerTask(
|
||||
taskId: string,
|
||||
body: BatchTriggerTaskRequestBody,
|
||||
options?: TriggerOptions,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
const encodedTaskId = encodeURIComponent(taskId);
|
||||
|
||||
return zodfetch(
|
||||
@@ -134,11 +158,11 @@ export class ApiClient {
|
||||
headers: this.#getHeaders(options?.spanParentAsLink ?? false),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
createUploadPayloadUrl(filename: string) {
|
||||
createUploadPayloadUrl(filename: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
CreateUploadPayloadUrlResponseBody,
|
||||
`${this.baseUrl}/api/v1/packets/${filename}`,
|
||||
@@ -146,11 +170,11 @@ export class ApiClient {
|
||||
method: "PUT",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
getPayloadUrl(filename: string) {
|
||||
getPayloadUrl(filename: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
CreateUploadPayloadUrlResponseBody,
|
||||
`${this.baseUrl}/api/v1/packets/${filename}`,
|
||||
@@ -158,11 +182,11 @@ export class ApiClient {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
retrieveRun(runId: string) {
|
||||
retrieveRun(runId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
RetrieveRunResponse,
|
||||
`${this.baseUrl}/api/v3/runs/${runId}`,
|
||||
@@ -170,11 +194,14 @@ export class ApiClient {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
listRuns(query?: ListRunsQueryParams): CursorPagePromise<typeof ListRunResponseItem> {
|
||||
listRuns(
|
||||
query?: ListRunsQueryParams,
|
||||
requestOptions?: ZodFetchOptions
|
||||
): CursorPagePromise<typeof ListRunResponseItem> {
|
||||
const searchParams = createSearchQueryForListRuns(query);
|
||||
|
||||
return zodfetchCursorPage(
|
||||
@@ -190,13 +217,14 @@ export class ApiClient {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
listProjectRuns(
|
||||
projectRef: string,
|
||||
query?: ListProjectRunsQueryParams
|
||||
query?: ListProjectRunsQueryParams,
|
||||
requestOptions?: ZodFetchOptions
|
||||
): CursorPagePromise<typeof ListRunResponseItem> {
|
||||
const searchParams = createSearchQueryForListRuns(query);
|
||||
|
||||
@@ -220,11 +248,11 @@ export class ApiClient {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
replayRun(runId: string) {
|
||||
replayRun(runId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
ReplayRunResponse,
|
||||
`${this.baseUrl}/api/v1/runs/${runId}/replay`,
|
||||
@@ -232,11 +260,11 @@ export class ApiClient {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
cancelRun(runId: string) {
|
||||
cancelRun(runId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
CanceledRunResponse,
|
||||
`${this.baseUrl}/api/v2/runs/${runId}/cancel`,
|
||||
@@ -244,11 +272,11 @@ export class ApiClient {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
rescheduleRun(runId: string, body: RescheduleRunRequestBody) {
|
||||
rescheduleRun(runId: string, body: RescheduleRunRequestBody, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
RetrieveRunResponse,
|
||||
`${this.baseUrl}/api/v1/runs/${runId}/reschedule`,
|
||||
@@ -257,19 +285,24 @@ export class ApiClient {
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
zodFetchOptions
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
createSchedule(options: CreateScheduleOptions) {
|
||||
return zodfetch(ScheduleObject, `${this.baseUrl}/api/v1/schedules`, {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
createSchedule(options: CreateScheduleOptions, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
ScheduleObject,
|
||||
`${this.baseUrl}/api/v1/schedules`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(options),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
listSchedules(options?: ListScheduleOptions) {
|
||||
listSchedules(options?: ListScheduleOptions, requestOptions?: ZodFetchOptions) {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
if (options?.page) {
|
||||
@@ -290,58 +323,94 @@ export class ApiClient {
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
retrieveSchedule(scheduleId: string) {
|
||||
return zodfetch(ScheduleObject, `${this.baseUrl}/api/v1/schedules/${scheduleId}`, {
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
});
|
||||
retrieveSchedule(scheduleId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
ScheduleObject,
|
||||
`${this.baseUrl}/api/v1/schedules/${scheduleId}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
updateSchedule(scheduleId: string, options: UpdateScheduleOptions) {
|
||||
return zodfetch(ScheduleObject, `${this.baseUrl}/api/v1/schedules/${scheduleId}`, {
|
||||
method: "PUT",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
updateSchedule(
|
||||
scheduleId: string,
|
||||
options: UpdateScheduleOptions,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
return zodfetch(
|
||||
ScheduleObject,
|
||||
`${this.baseUrl}/api/v1/schedules/${scheduleId}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(options),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
deactivateSchedule(scheduleId: string) {
|
||||
return zodfetch(ScheduleObject, `${this.baseUrl}/api/v1/schedules/${scheduleId}/deactivate`, {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
});
|
||||
deactivateSchedule(scheduleId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
ScheduleObject,
|
||||
`${this.baseUrl}/api/v1/schedules/${scheduleId}/deactivate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
activateSchedule(scheduleId: string) {
|
||||
return zodfetch(ScheduleObject, `${this.baseUrl}/api/v1/schedules/${scheduleId}/activate`, {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
});
|
||||
activateSchedule(scheduleId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
ScheduleObject,
|
||||
`${this.baseUrl}/api/v1/schedules/${scheduleId}/activate`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
deleteSchedule(scheduleId: string) {
|
||||
return zodfetch(DeletedScheduleObject, `${this.baseUrl}/api/v1/schedules/${scheduleId}`, {
|
||||
method: "DELETE",
|
||||
headers: this.#getHeaders(false),
|
||||
});
|
||||
deleteSchedule(scheduleId: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
DeletedScheduleObject,
|
||||
`${this.baseUrl}/api/v1/schedules/${scheduleId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.#getHeaders(false),
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
listEnvVars(projectRef: string, slug: string) {
|
||||
listEnvVars(projectRef: string, slug: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
EnvironmentVariables,
|
||||
`${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
importEnvVars(projectRef: string, slug: string, body: ImportEnvironmentVariablesParams) {
|
||||
importEnvVars(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
body: ImportEnvironmentVariablesParams,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
return zodfetch(
|
||||
EnvironmentVariableResponseBody,
|
||||
`${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}/import`,
|
||||
@@ -349,22 +418,29 @@ export class ApiClient {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
retrieveEnvVar(projectRef: string, slug: string, key: string) {
|
||||
retrieveEnvVar(projectRef: string, slug: string, key: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
EnvironmentVariableValue,
|
||||
`${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}/${key}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: this.#getHeaders(false),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
createEnvVar(projectRef: string, slug: string, body: CreateEnvironmentVariableRequestBody) {
|
||||
createEnvVar(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
body: CreateEnvironmentVariableRequestBody,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
return zodfetch(
|
||||
EnvironmentVariableResponseBody,
|
||||
`${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}`,
|
||||
@@ -372,7 +448,8 @@ export class ApiClient {
|
||||
method: "POST",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -380,7 +457,8 @@ export class ApiClient {
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
key: string,
|
||||
body: UpdateEnvironmentVariableRequestBody
|
||||
body: UpdateEnvironmentVariableRequestBody,
|
||||
requestOptions?: ZodFetchOptions
|
||||
) {
|
||||
return zodfetch(
|
||||
EnvironmentVariableResponseBody,
|
||||
@@ -389,18 +467,20 @@ export class ApiClient {
|
||||
method: "PUT",
|
||||
headers: this.#getHeaders(false),
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
deleteEnvVar(projectRef: string, slug: string, key: string) {
|
||||
deleteEnvVar(projectRef: string, slug: string, key: string, requestOptions?: ZodFetchOptions) {
|
||||
return zodfetch(
|
||||
EnvironmentVariableResponseBody,
|
||||
`${this.baseUrl}/api/v1/projects/${projectRef}/envvars/${slug}/${key}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.#getHeaders(false),
|
||||
}
|
||||
},
|
||||
mergeRequestOptions(this.defaultRequestOptions, requestOptions)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -483,3 +563,21 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar
|
||||
|
||||
return searchParams;
|
||||
}
|
||||
|
||||
export function mergeRequestOptions(
|
||||
defaultOptions: ZodFetchOptions,
|
||||
options?: ApiRequestOptions
|
||||
): ZodFetchOptions {
|
||||
if (!options) {
|
||||
return defaultOptions;
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
retry: {
|
||||
...defaultOptions.retry,
|
||||
...options.retry,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { ApiRequestOptions } from "../apiClient";
|
||||
|
||||
export type ApiClientConfiguration = {
|
||||
baseURL?: string;
|
||||
secretKey?: string;
|
||||
requestOptions?: ApiRequestOptions;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { z } from "zod";
|
||||
import { TaskRunError } from "./schemas/common";
|
||||
import nodePath from "node:path";
|
||||
|
||||
export class AbortTaskRunError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "AbortTaskRunError";
|
||||
}
|
||||
}
|
||||
|
||||
export function parseError(error: unknown): TaskRunError {
|
||||
if (error instanceof Error) {
|
||||
|
||||
@@ -70,6 +70,7 @@ export const TriggerTaskRequestBody = z.object({
|
||||
payloadType: z.string().optional(),
|
||||
delay: z.string().or(z.coerce.date()).optional(),
|
||||
ttl: z.string().or(z.number().nonnegative().int()).optional(),
|
||||
maxAttempts: z.number().int().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
@@ -133,6 +133,7 @@ export const TaskRun = z.object({
|
||||
createdAt: z.coerce.date(),
|
||||
startedAt: z.coerce.date().default(() => new Date()),
|
||||
idempotencyKey: z.string().optional(),
|
||||
maxAttempts: z.number().optional(),
|
||||
durationMs: z.number().default(0),
|
||||
costInCents: z.number().default(0),
|
||||
baseCostInCents: z.number().default(0),
|
||||
|
||||
@@ -48,4 +48,7 @@ export const SemanticInternalAttributes = {
|
||||
IDEMPOTENCY_KEY: "ctx.run.idempotencyKey",
|
||||
USAGE_DURATION_MS: "$usage.durationMs",
|
||||
USAGE_COST_IN_CENTS: "$usage.costInCents",
|
||||
RATE_LIMIT_LIMIT: "response.rateLimit.limit",
|
||||
RATE_LIMIT_REMAINING: "response.rateLimit.remaining",
|
||||
RATE_LIMIT_RESET: "response.rateLimit.reset",
|
||||
};
|
||||
|
||||
@@ -72,20 +72,22 @@ export class TriggerTracer {
|
||||
},
|
||||
parentContext,
|
||||
async (span) => {
|
||||
this.tracer
|
||||
.startSpan(
|
||||
name,
|
||||
{
|
||||
...options,
|
||||
attributes: {
|
||||
...attributes,
|
||||
[SemanticInternalAttributes.SPAN_PARTIAL]: true,
|
||||
[SemanticInternalAttributes.SPAN_ID]: span.spanContext().spanId,
|
||||
if (taskContext.ctx) {
|
||||
this.tracer
|
||||
.startSpan(
|
||||
name,
|
||||
{
|
||||
...options,
|
||||
attributes: {
|
||||
...attributes,
|
||||
[SemanticInternalAttributes.SPAN_PARTIAL]: true,
|
||||
[SemanticInternalAttributes.SPAN_ID]: span.spanContext().spanId,
|
||||
},
|
||||
},
|
||||
},
|
||||
parentContext
|
||||
)
|
||||
.end();
|
||||
parentContext
|
||||
)
|
||||
.end();
|
||||
}
|
||||
|
||||
const usageMeasurement = usage.start();
|
||||
|
||||
@@ -100,15 +102,17 @@ export class TriggerTracer {
|
||||
|
||||
throw e;
|
||||
} finally {
|
||||
const usageSample = usage.stop(usageMeasurement);
|
||||
const machine = taskContext.ctx?.machine;
|
||||
if (taskContext.ctx) {
|
||||
const usageSample = usage.stop(usageMeasurement);
|
||||
const machine = taskContext.ctx.machine;
|
||||
|
||||
span.setAttributes({
|
||||
[SemanticInternalAttributes.USAGE_DURATION_MS]: usageSample.cpuTime,
|
||||
[SemanticInternalAttributes.USAGE_COST_IN_CENTS]: machine?.centsPerMs
|
||||
? usageSample.cpuTime * machine.centsPerMs
|
||||
: 0,
|
||||
});
|
||||
span.setAttributes({
|
||||
[SemanticInternalAttributes.USAGE_DURATION_MS]: usageSample.cpuTime,
|
||||
[SemanticInternalAttributes.USAGE_COST_IN_CENTS]: machine?.centsPerMs
|
||||
? usageSample.cpuTime * machine.centsPerMs
|
||||
: 0,
|
||||
});
|
||||
}
|
||||
|
||||
span.end(clock.preciseNow());
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { calculateNextRetryDelay } from "../utils/retries";
|
||||
import { accessoryAttributes } from "../utils/styleAttributes";
|
||||
import { UsageMeasurement } from "../usage/types";
|
||||
import { ApiError, RateLimitError } from "../apiClient/errors";
|
||||
|
||||
export type TaskExecutorOptions = {
|
||||
tracingSDK: TracingSDK;
|
||||
@@ -455,7 +456,26 @@ export class TaskExecutor {
|
||||
return { status: "noop" };
|
||||
}
|
||||
|
||||
const delay = calculateNextRetryDelay(retry, execution.attempt.number);
|
||||
if (error instanceof Error && error.name === "AbortTaskRunError") {
|
||||
return { status: "skipped" };
|
||||
}
|
||||
|
||||
if (execution.run.maxAttempts) {
|
||||
retry.maxAttempts = Math.max(execution.run.maxAttempts, 1);
|
||||
}
|
||||
|
||||
let delay = calculateNextRetryDelay(retry, execution.attempt.number);
|
||||
|
||||
if (
|
||||
delay &&
|
||||
error instanceof Error &&
|
||||
error.name === "TriggerApiError" &&
|
||||
(error as ApiError).status === 429
|
||||
) {
|
||||
const rateLimitError = error as RateLimitError;
|
||||
|
||||
delay = rateLimitError.millisecondsUntilReset;
|
||||
}
|
||||
|
||||
if (
|
||||
execution.environment.type === "DEVELOPMENT" &&
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "TaskRun" ADD COLUMN "maxAttempts" INTEGER;
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[runtimeEnvironmentId,taskIdentifier,idempotencyKey]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail.
|
||||
- A unique constraint covering the columns `[runtimeEnvironmentId,taskIdentifier,idempotencyKey]` on the table `TaskRun` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "BatchTaskRun_runtimeEnvironmentId_taskIdentifier_idempotenc_key" ON "BatchTaskRun"(
|
||||
"runtimeEnvironmentId",
|
||||
"taskIdentifier",
|
||||
"idempotencyKey"
|
||||
);
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key";
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TaskRun_runtimeEnvironmentId_taskIdentifier_idempotencyKey_key" ON "TaskRun"(
|
||||
"runtimeEnvironmentId",
|
||||
"taskIdentifier",
|
||||
"idempotencyKey"
|
||||
);
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "TaskRun_runtimeEnvironmentId_idempotencyKey_key";
|
||||
@@ -1660,10 +1660,11 @@ model TaskRun {
|
||||
|
||||
concurrencyKey String?
|
||||
|
||||
delayUntil DateTime?
|
||||
queuedAt DateTime?
|
||||
ttl String?
|
||||
expiredAt DateTime?
|
||||
delayUntil DateTime?
|
||||
queuedAt DateTime?
|
||||
ttl String?
|
||||
expiredAt DateTime?
|
||||
maxAttempts Int?
|
||||
|
||||
batchItems BatchTaskRunItem[]
|
||||
dependency TaskRunDependency?
|
||||
@@ -1678,7 +1679,7 @@ model TaskRun {
|
||||
sourceBulkActionItems BulkActionItem[] @relation("SourceActionItemRun")
|
||||
destinationBulkActionItems BulkActionItem[] @relation("DestinationActionItemRun")
|
||||
|
||||
@@unique([runtimeEnvironmentId, idempotencyKey])
|
||||
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
|
||||
// Task activity graph
|
||||
@@index([projectId, createdAt, taskIdentifier])
|
||||
//Runs list
|
||||
@@ -2021,7 +2022,7 @@ model BatchTaskRun {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([runtimeEnvironmentId, idempotencyKey])
|
||||
@@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey])
|
||||
}
|
||||
|
||||
enum BatchTaskRunStatus {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ApiPromise,
|
||||
ApiRequestOptions,
|
||||
CreateEnvironmentVariableParams,
|
||||
EnvironmentVariableResponseBody,
|
||||
EnvironmentVariableValue,
|
||||
@@ -7,32 +8,45 @@ import type {
|
||||
ImportEnvironmentVariablesParams,
|
||||
UpdateEnvironmentVariableParams,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { apiClientManager, taskContext } from "@trigger.dev/core/v3";
|
||||
import {
|
||||
apiClientManager,
|
||||
isRequestOptions,
|
||||
mergeRequestOptions,
|
||||
taskContext,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { apiClientMissingError } from "./shared";
|
||||
import { tracer } from "./tracer";
|
||||
|
||||
export type { CreateEnvironmentVariableParams, ImportEnvironmentVariablesParams };
|
||||
|
||||
export function upload(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
params: ImportEnvironmentVariablesParams
|
||||
params: ImportEnvironmentVariablesParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function upload(
|
||||
params: ImportEnvironmentVariablesParams
|
||||
params: ImportEnvironmentVariablesParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function upload(
|
||||
projectRefOrParams: string | ImportEnvironmentVariablesParams,
|
||||
slug?: string,
|
||||
params?: ImportEnvironmentVariablesParams
|
||||
slugOrRequestOptions?: string | ApiRequestOptions,
|
||||
params?: ImportEnvironmentVariablesParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody> {
|
||||
let $projectRef: string;
|
||||
let $params: ImportEnvironmentVariablesParams;
|
||||
let $slug: string;
|
||||
const $requestOptions = overloadRequestOptions("upload", slugOrRequestOptions, requestOptions);
|
||||
|
||||
if (taskContext.ctx) {
|
||||
if (typeof projectRefOrParams === "string") {
|
||||
$projectRef = projectRefOrParams;
|
||||
$slug = slug ?? taskContext.ctx.environment.slug;
|
||||
$slug =
|
||||
typeof slugOrRequestOptions === "string"
|
||||
? slugOrRequestOptions
|
||||
: taskContext.ctx.environment.slug;
|
||||
|
||||
if (!params) {
|
||||
throw new Error("params is required");
|
||||
@@ -49,7 +63,7 @@ export function upload(
|
||||
throw new Error("projectRef is required");
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
if (!slugOrRequestOptions || typeof slugOrRequestOptions !== "string") {
|
||||
throw new Error("slug is required");
|
||||
}
|
||||
|
||||
@@ -58,7 +72,7 @@ export function upload(
|
||||
}
|
||||
|
||||
$projectRef = projectRefOrParams;
|
||||
$slug = slug;
|
||||
$slug = slugOrRequestOptions;
|
||||
$params = params;
|
||||
}
|
||||
|
||||
@@ -68,14 +82,27 @@ export function upload(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.importEnvVars($projectRef, $slug, $params);
|
||||
return apiClient.importEnvVars($projectRef, $slug, $params, $requestOptions);
|
||||
}
|
||||
|
||||
export function list(projectRef: string, slug: string): ApiPromise<EnvironmentVariables>;
|
||||
export function list(): ApiPromise<EnvironmentVariables>;
|
||||
export function list(projectRef?: string, slug?: string): ApiPromise<EnvironmentVariables> {
|
||||
const $projectRef = projectRef ?? taskContext.ctx?.project.ref;
|
||||
export function list(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariables>;
|
||||
export function list(requestOptions?: ApiRequestOptions): ApiPromise<EnvironmentVariables>;
|
||||
export function list(
|
||||
projectRefOrRequestOptions?: string | ApiRequestOptions,
|
||||
slug?: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariables> {
|
||||
const $projectRef = !isRequestOptions(projectRefOrRequestOptions)
|
||||
? projectRefOrRequestOptions
|
||||
: taskContext.ctx?.project.ref;
|
||||
const $slug = slug ?? taskContext.ctx?.environment.slug;
|
||||
let $requestOptions = isRequestOptions(projectRefOrRequestOptions)
|
||||
? projectRefOrRequestOptions
|
||||
: requestOptions;
|
||||
|
||||
if (!$projectRef) {
|
||||
throw new Error("projectRef is required");
|
||||
@@ -85,36 +112,52 @@ export function list(projectRef?: string, slug?: string): ApiPromise<Environment
|
||||
throw new Error("slug is required");
|
||||
}
|
||||
|
||||
$requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "envvars.list()",
|
||||
icon: "id-badge",
|
||||
},
|
||||
$requestOptions
|
||||
);
|
||||
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.listEnvVars($projectRef, $slug);
|
||||
return apiClient.listEnvVars($projectRef, $slug, $requestOptions);
|
||||
}
|
||||
|
||||
export function create(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
params: CreateEnvironmentVariableParams
|
||||
params: CreateEnvironmentVariableParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function create(
|
||||
params: CreateEnvironmentVariableParams
|
||||
params: CreateEnvironmentVariableParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function create(
|
||||
projectRefOrParams: string | CreateEnvironmentVariableParams,
|
||||
slug?: string,
|
||||
params?: CreateEnvironmentVariableParams
|
||||
slugOrRequestOptions?: string | ApiRequestOptions,
|
||||
params?: CreateEnvironmentVariableParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody> {
|
||||
let $projectRef: string;
|
||||
let $slug: string;
|
||||
let $params: CreateEnvironmentVariableParams;
|
||||
const $requestOptions = overloadRequestOptions("create", slugOrRequestOptions, requestOptions);
|
||||
|
||||
if (taskContext.ctx) {
|
||||
if (typeof projectRefOrParams === "string") {
|
||||
$projectRef = projectRefOrParams;
|
||||
$slug = slug ?? taskContext.ctx.environment.slug;
|
||||
$slug =
|
||||
typeof slugOrRequestOptions === "string"
|
||||
? slugOrRequestOptions
|
||||
: taskContext.ctx.environment.slug;
|
||||
|
||||
if (!params) {
|
||||
throw new Error("params is required");
|
||||
@@ -131,7 +174,7 @@ export function create(
|
||||
throw new Error("projectRef is required");
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
if (!slugOrRequestOptions || typeof slugOrRequestOptions !== "string") {
|
||||
throw new Error("slug is required");
|
||||
}
|
||||
|
||||
@@ -140,7 +183,7 @@ export function create(
|
||||
}
|
||||
|
||||
$projectRef = projectRefOrParams;
|
||||
$slug = slug;
|
||||
$slug = slugOrRequestOptions;
|
||||
$params = params;
|
||||
}
|
||||
|
||||
@@ -150,27 +193,36 @@ export function create(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.createEnvVar($projectRef, $slug, $params);
|
||||
return apiClient.createEnvVar($projectRef, $slug, $params, $requestOptions);
|
||||
}
|
||||
|
||||
export function retrieve(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
name: string
|
||||
name: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableValue>;
|
||||
export function retrieve(
|
||||
name: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableValue>;
|
||||
export function retrieve(name: string): ApiPromise<EnvironmentVariableValue>;
|
||||
export function retrieve(
|
||||
projectRefOrName: string,
|
||||
slug?: string,
|
||||
name?: string
|
||||
slugOrRequestOptions?: string | ApiRequestOptions,
|
||||
name?: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableValue> {
|
||||
let $projectRef: string;
|
||||
let $slug: string;
|
||||
let $name: string;
|
||||
const $requestOptions = overloadRequestOptions("retrieve", slugOrRequestOptions, requestOptions);
|
||||
|
||||
if (typeof name === "string") {
|
||||
$projectRef = projectRefOrName;
|
||||
$slug = slug!;
|
||||
$slug =
|
||||
typeof slugOrRequestOptions === "string"
|
||||
? slugOrRequestOptions
|
||||
: taskContext.ctx?.environment.slug!;
|
||||
$name = name;
|
||||
} else {
|
||||
$projectRef = taskContext.ctx?.project.ref!;
|
||||
@@ -192,27 +244,36 @@ export function retrieve(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.retrieveEnvVar($projectRef, $slug, $name);
|
||||
return apiClient.retrieveEnvVar($projectRef, $slug, $name, $requestOptions);
|
||||
}
|
||||
|
||||
export function del(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
name: string
|
||||
name: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function del(
|
||||
name: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function del(name: string): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function del(
|
||||
projectRefOrName: string,
|
||||
slug?: string,
|
||||
name?: string
|
||||
slugOrRequestOptions?: string | ApiRequestOptions,
|
||||
name?: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody> {
|
||||
let $projectRef: string;
|
||||
let $slug: string;
|
||||
let $name: string;
|
||||
const $requestOptions = overloadRequestOptions("del", slugOrRequestOptions, requestOptions);
|
||||
|
||||
if (typeof name === "string") {
|
||||
$projectRef = projectRefOrName;
|
||||
$slug = slug!;
|
||||
$slug =
|
||||
typeof slugOrRequestOptions === "string"
|
||||
? slugOrRequestOptions
|
||||
: taskContext.ctx?.environment.slug!;
|
||||
$name = name;
|
||||
} else {
|
||||
$projectRef = taskContext.ctx?.project.ref!;
|
||||
@@ -234,35 +295,42 @@ export function del(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.deleteEnvVar($projectRef, $slug, $name);
|
||||
return apiClient.deleteEnvVar($projectRef, $slug, $name, $requestOptions);
|
||||
}
|
||||
|
||||
export function update(
|
||||
projectRef: string,
|
||||
slug: string,
|
||||
name: string,
|
||||
params: UpdateEnvironmentVariableParams
|
||||
params: UpdateEnvironmentVariableParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function update(
|
||||
name: string,
|
||||
params: UpdateEnvironmentVariableParams
|
||||
params: UpdateEnvironmentVariableParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody>;
|
||||
export function update(
|
||||
projectRefOrName: string,
|
||||
slugOrParams: string | UpdateEnvironmentVariableParams,
|
||||
name?: string,
|
||||
params?: UpdateEnvironmentVariableParams
|
||||
nameOrRequestOptions?: string | ApiRequestOptions,
|
||||
params?: UpdateEnvironmentVariableParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<EnvironmentVariableResponseBody> {
|
||||
let $projectRef: string;
|
||||
let $slug: string;
|
||||
let $name: string;
|
||||
let $params: UpdateEnvironmentVariableParams;
|
||||
const $requestOptions = overloadRequestOptions("update", nameOrRequestOptions, requestOptions);
|
||||
|
||||
if (taskContext.ctx) {
|
||||
if (typeof slugOrParams === "string") {
|
||||
$projectRef = slugOrParams;
|
||||
$slug = slugOrParams ?? taskContext.ctx.environment.slug;
|
||||
$name = name!;
|
||||
$name =
|
||||
typeof nameOrRequestOptions === "string"
|
||||
? nameOrRequestOptions
|
||||
: taskContext.ctx.environment.slug;
|
||||
|
||||
if (!params) {
|
||||
throw new Error("params is required");
|
||||
@@ -300,5 +368,31 @@ export function update(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.updateEnvVar($projectRef, $slug, $name, $params);
|
||||
return apiClient.updateEnvVar($projectRef, $slug, $name, $params, $requestOptions);
|
||||
}
|
||||
|
||||
function overloadRequestOptions(
|
||||
name: string,
|
||||
slugOrRequestOptions?: string | ApiRequestOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiRequestOptions {
|
||||
if (isRequestOptions(slugOrRequestOptions)) {
|
||||
return mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: `envvars.${name}()`,
|
||||
icon: "id-badge",
|
||||
},
|
||||
slugOrRequestOptions
|
||||
);
|
||||
} else {
|
||||
return mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: `envvars.${name}()`,
|
||||
icon: "id-badge",
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { AbortTaskRunError } from "@trigger.dev/core/v3";
|
||||
@@ -0,0 +1,93 @@
|
||||
import { taskContext } from "@trigger.dev/core/v3";
|
||||
|
||||
export const idempotencyKeys = {
|
||||
create: createIdempotencyKey,
|
||||
};
|
||||
|
||||
declare const __brand: unique symbol;
|
||||
type Brand<B> = { [__brand]: B };
|
||||
type Branded<T, B> = T & Brand<B>;
|
||||
|
||||
export type IdempotencyKey = Branded<string, "IdempotencyKey">;
|
||||
|
||||
export function isIdempotencyKey(
|
||||
value: string | string[] | IdempotencyKey
|
||||
): value is IdempotencyKey {
|
||||
// Cannot check the brand at runtime because it doesn't exist (it's a TypeScript-only construct)
|
||||
return typeof value === "string" && value.length === 64;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deterministic idempotency key based on the provided key material.
|
||||
*
|
||||
* If running inside a task, the task run ID is automatically included in the key material, giving you a unique key per task run.
|
||||
* This ensures that a given child task is only triggered once per task run, even if the parent task is retried.
|
||||
*
|
||||
* @param {string | string[]} key The key material to create the idempotency key from.
|
||||
* @param {object} [options] Additional options.
|
||||
* @param {"run" | "attempt" | "global"} [options.scope="run"] The scope of the idempotency key.
|
||||
*
|
||||
* @returns {Promise<IdempotencyKey>} The idempotency key as a branded string.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
*
|
||||
* export const myTask = task({
|
||||
* id: "my-task",
|
||||
* run: async (payload: any) => {
|
||||
* const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
*
|
||||
* // Use the idempotency key when triggering child tasks
|
||||
* await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* You can also use the `scope` parameter to create a key that is unique per task run, task run attempts (retries of the same run), or globally:
|
||||
*
|
||||
* ```typescript
|
||||
* await idempotencyKeys.create("my-task-key", { scope: "attempt" }); // Creates a key that is unique per task run attempt
|
||||
* await idempotencyKeys.create("my-task-key", { scope: "global" }); // Skips including the task run ID
|
||||
* ```
|
||||
*/
|
||||
async function createIdempotencyKey(
|
||||
key: string | string[],
|
||||
options?: { scope?: "run" | "attempt" | "global" }
|
||||
): Promise<IdempotencyKey> {
|
||||
const idempotencyKey = await generateIdempotencyKey(
|
||||
[...(Array.isArray(key) ? key : [key])].concat(injectScope(options?.scope ?? "run"))
|
||||
);
|
||||
|
||||
return idempotencyKey as IdempotencyKey;
|
||||
}
|
||||
|
||||
function injectScope(scope: "run" | "attempt" | "global"): string[] {
|
||||
switch (scope) {
|
||||
case "run": {
|
||||
if (taskContext?.ctx) {
|
||||
return [taskContext.ctx.run.id];
|
||||
}
|
||||
}
|
||||
case "attempt": {
|
||||
if (taskContext?.ctx) {
|
||||
return [taskContext.ctx.attempt.id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async function generateIdempotencyKey(keyMaterial: string[]) {
|
||||
const hash = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(keyMaterial.join("-"))
|
||||
);
|
||||
|
||||
// Return a hex string, using cross-runtime compatible methods
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((byte) => byte.toString(16).padStart(2, "0"))
|
||||
.join("");
|
||||
}
|
||||
@@ -5,6 +5,7 @@ export { queue } from "./shared";
|
||||
export * from "./tasks";
|
||||
export * from "./wait";
|
||||
export * from "./usage";
|
||||
export * from "./idempotencyKeys";
|
||||
export type { Context };
|
||||
|
||||
import type { Context } from "./shared";
|
||||
|
||||
@@ -77,6 +77,12 @@ function onThrow<T>(
|
||||
|
||||
innerSpan.setStatus({ code: SpanStatusCode.ERROR });
|
||||
|
||||
if (e instanceof Error && e.name === "AbortTaskRunError") {
|
||||
innerSpan.end();
|
||||
|
||||
throw e;
|
||||
}
|
||||
|
||||
const nextRetryDelay = calculateNextRetryDelay(opts, attempt);
|
||||
|
||||
if (!nextRetryDelay) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
ApiRequestOptions,
|
||||
ListProjectRunsQueryParams,
|
||||
ListRunsQueryParams,
|
||||
RescheduleRunRequestBody,
|
||||
@@ -10,9 +11,14 @@ import {
|
||||
ListRunResponseItem,
|
||||
ReplayRunResponse,
|
||||
RetrieveRunResponse,
|
||||
accessoryAttributes,
|
||||
apiClientManager,
|
||||
flattenAttributes,
|
||||
isRequestOptions,
|
||||
mergeRequestOptions,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { Prettify, RunHandle, apiClientMissingError } from "./shared";
|
||||
import { tracer } from "./tracer";
|
||||
|
||||
export type RetrieveRunResult<TOutput> = Prettify<
|
||||
TOutput extends RunHandle<infer THandleOutput>
|
||||
@@ -33,12 +39,17 @@ export type ListRunsItem = ListRunResponseItem;
|
||||
|
||||
function listRuns(
|
||||
projectRef: string,
|
||||
params?: ListProjectRunsQueryParams
|
||||
params?: ListProjectRunsQueryParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): CursorPagePromise<typeof ListRunResponseItem>;
|
||||
function listRuns(
|
||||
params?: ListRunsQueryParams,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): CursorPagePromise<typeof ListRunResponseItem>;
|
||||
function listRuns(params?: ListRunsQueryParams): CursorPagePromise<typeof ListRunResponseItem>;
|
||||
function listRuns(
|
||||
paramsOrProjectRef?: ListRunsQueryParams | string,
|
||||
params?: ListRunsQueryParams | ListProjectRunsQueryParams
|
||||
paramsOrOptions?: ListRunsQueryParams | ListProjectRunsQueryParams | ApiRequestOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): CursorPagePromise<typeof ListRunResponseItem> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -46,15 +57,91 @@ function listRuns(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
const $requestOptions = listRunsRequestOptions(
|
||||
paramsOrProjectRef,
|
||||
paramsOrOptions,
|
||||
requestOptions
|
||||
);
|
||||
|
||||
if (typeof paramsOrProjectRef === "string") {
|
||||
return apiClient.listProjectRuns(paramsOrProjectRef, params);
|
||||
if (isRequestOptions(paramsOrOptions)) {
|
||||
return apiClient.listProjectRuns(paramsOrProjectRef, {}, $requestOptions);
|
||||
} else {
|
||||
return apiClient.listProjectRuns(paramsOrProjectRef, paramsOrOptions, $requestOptions);
|
||||
}
|
||||
}
|
||||
|
||||
return apiClient.listRuns(params);
|
||||
return apiClient.listRuns(paramsOrProjectRef, $requestOptions);
|
||||
}
|
||||
|
||||
function listRunsRequestOptions(
|
||||
paramsOrProjectRef?: ListRunsQueryParams | string,
|
||||
paramsOrOptions?: ListRunsQueryParams | ListProjectRunsQueryParams | ApiRequestOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiRequestOptions {
|
||||
if (typeof paramsOrProjectRef === "string") {
|
||||
if (isRequestOptions(paramsOrOptions)) {
|
||||
return mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.list()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
projectRef: paramsOrProjectRef,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: paramsOrProjectRef,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
paramsOrOptions
|
||||
);
|
||||
} else {
|
||||
return mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.list()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
projectRef: paramsOrProjectRef,
|
||||
...flattenAttributes(paramsOrOptions as Record<string, unknown>, "queryParams"),
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: paramsOrProjectRef,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.list()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
...flattenAttributes(paramsOrProjectRef as Record<string, unknown>, "queryParams"),
|
||||
},
|
||||
},
|
||||
isRequestOptions(paramsOrOptions) ? paramsOrOptions : requestOptions
|
||||
);
|
||||
}
|
||||
|
||||
function retrieveRun<TRunId extends RunHandle<any> | string>(
|
||||
runId: TRunId
|
||||
runId: TRunId,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<RetrieveRunResult<TRunId>> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -62,36 +149,108 @@ function retrieveRun<TRunId extends RunHandle<any> | string>(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.retrieve()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
runId: typeof runId === "string" ? runId : runId.id,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: typeof runId === "string" ? runId : runId.id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
if (typeof runId === "string") {
|
||||
return apiClient.retrieveRun(runId) as ApiPromise<RetrieveRunResult<TRunId>>;
|
||||
return apiClient.retrieveRun(runId, $requestOptions) as ApiPromise<RetrieveRunResult<TRunId>>;
|
||||
} else {
|
||||
return apiClient.retrieveRun(runId.id) as ApiPromise<RetrieveRunResult<TRunId>>;
|
||||
return apiClient.retrieveRun(runId.id, $requestOptions) as ApiPromise<
|
||||
RetrieveRunResult<TRunId>
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
function replayRun(runId: string): ApiPromise<ReplayRunResponse> {
|
||||
function replayRun(
|
||||
runId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<ReplayRunResponse> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.replayRun(runId);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.replay()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
runId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: runId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.replayRun(runId, $requestOptions);
|
||||
}
|
||||
|
||||
function cancelRun(runId: string): ApiPromise<CanceledRunResponse> {
|
||||
function cancelRun(
|
||||
runId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<CanceledRunResponse> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.cancelRun(runId);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.cancel()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
runId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: runId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.cancelRun(runId, $requestOptions);
|
||||
}
|
||||
|
||||
function rescheduleRun(
|
||||
runId: string,
|
||||
body: RescheduleRunRequestBody
|
||||
body: RescheduleRunRequestBody,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<RetrieveRunResponse> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -99,17 +258,43 @@ function rescheduleRun(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.rescheduleRun(runId, body);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "runs.reschedule()",
|
||||
icon: "runs",
|
||||
attributes: {
|
||||
runId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: runId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.rescheduleRun(runId, body, $requestOptions);
|
||||
}
|
||||
|
||||
export type PollOptions = { pollIntervalMs?: number };
|
||||
|
||||
const MAX_POLL_ATTEMPTS = 500;
|
||||
|
||||
async function poll<TRunHandle extends RunHandle<any> | string>(
|
||||
handle: TRunHandle,
|
||||
options?: { pollIntervalMs?: number }
|
||||
options?: { pollIntervalMs?: number },
|
||||
requestOptions?: ApiRequestOptions
|
||||
) {
|
||||
while (true) {
|
||||
const run = await runs.retrieve(handle);
|
||||
let attempts = 0;
|
||||
|
||||
while (attempts++ < MAX_POLL_ATTEMPTS) {
|
||||
const run = await runs.retrieve(handle, requestOptions);
|
||||
|
||||
if (run.isCompleted) {
|
||||
return run;
|
||||
@@ -117,4 +302,6 @@ async function poll<TRunHandle extends RunHandle<any> | string>(
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, options?.pollIntervalMs ?? 1000));
|
||||
}
|
||||
|
||||
throw new Error(`Run ${handle} did not complete after ${MAX_POLL_ATTEMPTS} attempts`);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import {
|
||||
ApiPromise,
|
||||
ApiRequestOptions,
|
||||
DeletedScheduleObject,
|
||||
InitOutput,
|
||||
OffsetLimitPagePromise,
|
||||
ScheduleObject,
|
||||
TimezonesResult,
|
||||
accessoryAttributes,
|
||||
apiClientManager,
|
||||
mergeRequestOptions,
|
||||
taskCatalog,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { zodfetch } from "@trigger.dev/core/v3/zodfetch";
|
||||
import { Task, TaskOptions, apiClientMissingError, createTask } from "../shared";
|
||||
import * as SchedulesAPI from "./api";
|
||||
import { tracer } from "../tracer";
|
||||
|
||||
export function task<TIdentifier extends string, TOutput, TInitOutput extends InitOutput>(
|
||||
params: TaskOptions<TIdentifier, SchedulesAPI.ScheduledTaskPayload, TOutput, TInitOutput>
|
||||
@@ -34,14 +38,37 @@ export function task<TIdentifier extends string, TOutput, TInitOutput extends In
|
||||
* @param options.deduplicationKey - An optional deduplication key for the schedule
|
||||
* @returns The created schedule
|
||||
*/
|
||||
export function create(options: SchedulesAPI.CreateScheduleOptions): ApiPromise<ScheduleObject> {
|
||||
export function create(
|
||||
options: SchedulesAPI.CreateScheduleOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<ScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.createSchedule(options);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.create()",
|
||||
icon: "clock",
|
||||
attributes: {
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: options.cron,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.createSchedule(options, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,14 +76,38 @@ export function create(options: SchedulesAPI.CreateScheduleOptions): ApiPromise<
|
||||
* @param scheduleId - The ID of the schedule to retrieve
|
||||
* @returns The retrieved schedule
|
||||
*/
|
||||
export function retrieve(scheduleId: string): ApiPromise<ScheduleObject> {
|
||||
export function retrieve(
|
||||
scheduleId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<ScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.retrieveSchedule(scheduleId);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.retrieve()",
|
||||
icon: "clock",
|
||||
attributes: {
|
||||
scheduleId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: scheduleId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.retrieveSchedule(scheduleId, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,7 +122,8 @@ export function retrieve(scheduleId: string): ApiPromise<ScheduleObject> {
|
||||
*/
|
||||
export function update(
|
||||
scheduleId: string,
|
||||
options: SchedulesAPI.UpdateScheduleOptions
|
||||
options: SchedulesAPI.UpdateScheduleOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<ScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -79,49 +131,142 @@ export function update(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.updateSchedule(scheduleId, options);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.update()",
|
||||
icon: "clock",
|
||||
attributes: {
|
||||
scheduleId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: scheduleId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.updateSchedule(scheduleId, options, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a schedule
|
||||
* @param scheduleId - The ID of the schedule to delete
|
||||
*/
|
||||
export function del(scheduleId: string): ApiPromise<DeletedScheduleObject> {
|
||||
export function del(
|
||||
scheduleId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<DeletedScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.deleteSchedule(scheduleId);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.delete()",
|
||||
icon: "clock",
|
||||
attributes: {
|
||||
scheduleId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: scheduleId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.deleteSchedule(scheduleId, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivates a schedule
|
||||
* @param scheduleId - The ID of the schedule to deactivate
|
||||
*/
|
||||
export function deactivate(scheduleId: string): ApiPromise<ScheduleObject> {
|
||||
export function deactivate(
|
||||
scheduleId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<ScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.deactivateSchedule(scheduleId);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.deactivate()",
|
||||
icon: "clock",
|
||||
attributes: {
|
||||
scheduleId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: scheduleId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.deactivateSchedule(scheduleId, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates a schedule
|
||||
* @param scheduleId - The ID of the schedule to activate
|
||||
*/
|
||||
export function activate(scheduleId: string): ApiPromise<ScheduleObject> {
|
||||
export function activate(
|
||||
scheduleId: string,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): ApiPromise<ScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
if (!apiClient) {
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.activateSchedule(scheduleId);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.activate()",
|
||||
icon: "clock",
|
||||
attributes: {
|
||||
scheduleId,
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: scheduleId,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.activateSchedule(scheduleId, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -132,7 +277,8 @@ export function activate(scheduleId: string): ApiPromise<ScheduleObject> {
|
||||
* @returns The list of schedules
|
||||
*/
|
||||
export function list(
|
||||
options?: SchedulesAPI.ListScheduleOptions
|
||||
options?: SchedulesAPI.ListScheduleOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): OffsetLimitPagePromise<typeof ScheduleObject> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -140,7 +286,16 @@ export function list(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
return apiClient.listSchedules(options);
|
||||
const $requestOptions = mergeRequestOptions(
|
||||
{
|
||||
tracer,
|
||||
name: "schedules.list()",
|
||||
icon: "clock",
|
||||
},
|
||||
requestOptions
|
||||
);
|
||||
|
||||
return apiClient.listSchedules(options, $requestOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
SEMATTRS_MESSAGING_SYSTEM,
|
||||
} from "@opentelemetry/semantic-conventions";
|
||||
import {
|
||||
ApiPromise,
|
||||
ApiRequestOptions,
|
||||
BatchTaskRunExecutionResult,
|
||||
FailureFnParams,
|
||||
HandleErrorFnParams,
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
import * as packageJson from "../../package.json";
|
||||
import { tracer } from "./tracer";
|
||||
import { PollOptions, RetrieveRunResult, runs } from "./runs";
|
||||
import { IdempotencyKey, idempotencyKeys, isIdempotencyKey } from "./idempotencyKeys";
|
||||
|
||||
export type Context = TaskRunContext;
|
||||
|
||||
@@ -362,7 +363,52 @@ export type TaskIdentifier<TTask extends AnyTask> = TTask extends Task<infer TId
|
||||
: never;
|
||||
|
||||
export type TaskRunOptions = {
|
||||
idempotencyKey?: string;
|
||||
/**
|
||||
* A unique key that can be used to ensure that a task is only triggered once per key.
|
||||
*
|
||||
* You can use `idempotencyKeys.create` to create an idempotency key first, and then pass it to the task options.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* import { idempotencyKeys, task } from "@trigger.dev/sdk/v3";
|
||||
*
|
||||
* export const myTask = task({
|
||||
* id: "my-task",
|
||||
* run: async (payload: any) => {
|
||||
* // scoped to the task run by default
|
||||
* const idempotencyKey = await idempotencyKeys.create("my-task-key");
|
||||
*
|
||||
* // Use the idempotency key when triggering child tasks
|
||||
* await childTask.triggerAndWait(payload, { idempotencyKey });
|
||||
*
|
||||
* // scoped globally, does not include the task run ID
|
||||
* const globalIdempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" });
|
||||
*
|
||||
* await childTask.triggerAndWait(payload, { idempotencyKey: globalIdempotencyKey });
|
||||
*
|
||||
* // You can also pass a string directly, which is the same as a global idempotency key
|
||||
* await childTask.triggerAndWait(payload, { idempotencyKey: "my-very-unique-key" });
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* When triggering a task inside another task, we automatically inject the run ID into the key material.
|
||||
*
|
||||
* If you are triggering a task from your backend, ensure you include some sufficiently unique key material to prevent collisions.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* ```typescript
|
||||
* import { idempotencyKeys, tasks } from "@trigger.dev/sdk/v3";
|
||||
*
|
||||
* // Somewhere in your backend
|
||||
* const idempotencyKey = await idempotenceKeys.create(["my-task-trigger", "user-123"]);
|
||||
* await tasks.trigger("my-task", { foo: "bar" }, { idempotencyKey });
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
idempotencyKey?: IdempotencyKey | string | string[];
|
||||
maxAttempts?: number;
|
||||
queue?: TaskRunConcurrencyOptions;
|
||||
concurrencyKey?: string;
|
||||
@@ -427,49 +473,40 @@ export function createTask<
|
||||
|
||||
const payloadPacket = await stringifyIO(payload);
|
||||
|
||||
const handle = await tracer.startActiveSpan(
|
||||
taskMetadata ? "Trigger" : `${params.id} trigger()`,
|
||||
async (span) => {
|
||||
const response = await apiClient.triggerTask(
|
||||
params.id,
|
||||
{
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: options?.queue ?? params.queue,
|
||||
concurrencyKey: options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: options?.idempotencyKey,
|
||||
delay: options?.delay,
|
||||
ttl: options?.ttl,
|
||||
},
|
||||
},
|
||||
{ spanParentAsLink: true }
|
||||
);
|
||||
|
||||
span.setAttribute("messaging.message.id", response.id);
|
||||
|
||||
return response;
|
||||
},
|
||||
const handle = await apiClient.triggerTask(
|
||||
params.id,
|
||||
{
|
||||
kind: SpanKind.PRODUCER,
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: options?.queue ?? params.queue,
|
||||
concurrencyKey: options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: await makeKey(options?.idempotencyKey),
|
||||
delay: options?.delay,
|
||||
ttl: options?.ttl,
|
||||
maxAttempts: options?.maxAttempts,
|
||||
},
|
||||
},
|
||||
{ spanParentAsLink: true },
|
||||
{
|
||||
name: taskMetadata ? `${taskMetadata.exportName}.trigger()` : `trigger()`,
|
||||
tracer,
|
||||
icon: "trigger",
|
||||
attributes: {
|
||||
[SEMATTRS_MESSAGING_OPERATION]: "publish",
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "trigger",
|
||||
["messaging.client_id"]: taskContext.worker?.id,
|
||||
[SEMATTRS_MESSAGING_DESTINATION]: params.queue?.name ?? params.id,
|
||||
[SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev",
|
||||
...(taskMetadata
|
||||
? accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: `${taskMetadata.exportName}.trigger()`,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
})
|
||||
: {}),
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: params.id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -485,68 +522,59 @@ export function createTask<
|
||||
|
||||
const taskMetadata = taskCatalog.getTaskMetadata(params.id);
|
||||
|
||||
const response = await tracer.startActiveSpan(
|
||||
taskMetadata ? "Batch trigger" : `${params.id} batchTrigger()`,
|
||||
async (span) => {
|
||||
const response = await apiClient.batchTriggerTask(
|
||||
params.id,
|
||||
{
|
||||
items: await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const payloadPacket = await stringifyIO(item.payload);
|
||||
|
||||
return {
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: item.options?.queue ?? params.queue,
|
||||
concurrencyKey: item.options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: item.options?.idempotencyKey,
|
||||
delay: item.options?.delay,
|
||||
ttl: item.options?.ttl,
|
||||
},
|
||||
};
|
||||
})
|
||||
),
|
||||
},
|
||||
{ spanParentAsLink: true }
|
||||
);
|
||||
|
||||
span.setAttribute("messaging.message.id", response.batchId);
|
||||
|
||||
const handle = {
|
||||
batchId: response.batchId,
|
||||
runs: response.runs.map((id) => ({ id })),
|
||||
};
|
||||
|
||||
return handle as BatchRunHandle<TOutput>;
|
||||
},
|
||||
const response = await apiClient.batchTriggerTask(
|
||||
params.id,
|
||||
{
|
||||
kind: SpanKind.PRODUCER,
|
||||
items: await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const payloadPacket = await stringifyIO(item.payload);
|
||||
|
||||
return {
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: item.options?.queue ?? params.queue,
|
||||
concurrencyKey: item.options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: await makeKey(item.options?.idempotencyKey),
|
||||
delay: item.options?.delay,
|
||||
ttl: item.options?.ttl,
|
||||
maxAttempts: item.options?.maxAttempts,
|
||||
},
|
||||
};
|
||||
})
|
||||
),
|
||||
},
|
||||
{ spanParentAsLink: true },
|
||||
{
|
||||
name: taskMetadata ? `${taskMetadata.exportName}.batchTrigger()` : `batchTrigger()`,
|
||||
icon: "trigger",
|
||||
tracer,
|
||||
attributes: {
|
||||
[SEMATTRS_MESSAGING_OPERATION]: "publish",
|
||||
["messaging.batch.message_count"]: items.length,
|
||||
["messaging.client_id"]: taskContext.worker?.id,
|
||||
[SEMATTRS_MESSAGING_DESTINATION]: params.queue?.name ?? params.id,
|
||||
[SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev",
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "trigger",
|
||||
...(taskMetadata
|
||||
? accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: `${taskMetadata.exportName}.batchTrigger()`,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
})
|
||||
: {}),
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: params.id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return response;
|
||||
const handle = {
|
||||
batchId: response.batchId,
|
||||
runs: response.runs.map((id) => ({ id })),
|
||||
};
|
||||
|
||||
return handle as BatchRunHandle<TOutput>;
|
||||
},
|
||||
triggerAndWait: async (payload, options) => {
|
||||
const ctx = taskContext.ctx;
|
||||
@@ -566,7 +594,7 @@ export function createTask<
|
||||
const payloadPacket = await stringifyIO(payload);
|
||||
|
||||
return await tracer.startActiveSpan(
|
||||
taskMetadata ? "Trigger" : `${params.id} triggerAndWait()`,
|
||||
taskMetadata ? `${taskMetadata.exportName}.triggerAndWait()` : `triggerAndWait()`,
|
||||
async (span) => {
|
||||
const response = await apiClient.triggerTask(params.id, {
|
||||
payload: payloadPacket.data,
|
||||
@@ -577,9 +605,10 @@ export function createTask<
|
||||
concurrencyKey: options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: options?.idempotencyKey,
|
||||
idempotencyKey: await makeKey(options?.idempotencyKey),
|
||||
delay: options?.delay,
|
||||
ttl: options?.ttl,
|
||||
maxAttempts: options?.maxAttempts,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -617,17 +646,15 @@ export function createTask<
|
||||
["messaging.client_id"]: taskContext.worker?.id,
|
||||
[SEMATTRS_MESSAGING_DESTINATION]: params.queue?.name ?? params.id,
|
||||
[SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev",
|
||||
...(taskMetadata
|
||||
? accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: `${taskMetadata.exportName}.triggerAndWait()`,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
})
|
||||
: {}),
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: params.id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -648,7 +675,7 @@ export function createTask<
|
||||
const taskMetadata = taskCatalog.getTaskMetadata(params.id);
|
||||
|
||||
return await tracer.startActiveSpan(
|
||||
taskMetadata ? "Batch trigger" : `${params.id} batchTriggerAndWait()`,
|
||||
taskMetadata ? `${taskMetadata.exportName}.batchTriggerAndWait()` : `batchTriggerAndWait()`,
|
||||
async (span) => {
|
||||
const response = await apiClient.batchTriggerTask(params.id, {
|
||||
items: await Promise.all(
|
||||
@@ -663,9 +690,10 @@ export function createTask<
|
||||
concurrencyKey: item.options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: item.options?.idempotencyKey,
|
||||
idempotencyKey: await makeKey(item.options?.idempotencyKey),
|
||||
delay: item.options?.delay,
|
||||
ttl: item.options?.ttl,
|
||||
maxAttempts: item.options?.maxAttempts,
|
||||
},
|
||||
};
|
||||
})
|
||||
@@ -752,17 +780,15 @@ export function createTask<
|
||||
[SEMATTRS_MESSAGING_DESTINATION]: params.queue?.name ?? params.id,
|
||||
[SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev",
|
||||
[SemanticInternalAttributes.STYLE_ICON]: "trigger",
|
||||
...(taskMetadata
|
||||
? accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: `${taskMetadata.exportName}.batchTriggerAndWait()`,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
})
|
||||
: {}),
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: params.id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -809,7 +835,8 @@ export function createTask<
|
||||
export async function trigger<TTask extends AnyTask>(
|
||||
id: TaskIdentifier<TTask>,
|
||||
payload: TaskPayload<TTask>,
|
||||
options?: TaskRunOptions
|
||||
options?: TaskRunOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): Promise<TaskOutputHandle<TTask>> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -819,18 +846,45 @@ export async function trigger<TTask extends AnyTask>(
|
||||
|
||||
const payloadPacket = await stringifyIO(payload);
|
||||
|
||||
const handle = await apiClient.triggerTask(id, {
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: options?.queue,
|
||||
concurrencyKey: options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: options?.idempotencyKey,
|
||||
delay: options?.delay,
|
||||
ttl: options?.ttl,
|
||||
const handle = await apiClient.triggerTask(
|
||||
id,
|
||||
{
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: options?.queue,
|
||||
concurrencyKey: options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: await makeKey(options?.idempotencyKey),
|
||||
delay: options?.delay,
|
||||
ttl: options?.ttl,
|
||||
maxAttempts: options?.maxAttempts,
|
||||
},
|
||||
},
|
||||
});
|
||||
{
|
||||
spanParentAsLink: true,
|
||||
},
|
||||
{
|
||||
name: `tasks.trigger()`,
|
||||
tracer,
|
||||
icon: "trigger",
|
||||
attributes: {
|
||||
[SEMATTRS_MESSAGING_OPERATION]: "publish",
|
||||
["messaging.client_id"]: taskContext.worker?.id,
|
||||
[SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
...requestOptions,
|
||||
}
|
||||
);
|
||||
|
||||
return handle as TaskOutputHandle<TTask>;
|
||||
}
|
||||
@@ -853,16 +907,18 @@ export async function trigger<TTask extends AnyTask>(
|
||||
export async function triggerAndPoll<TTask extends AnyTask>(
|
||||
id: TaskIdentifier<TTask>,
|
||||
payload: TaskPayload<TTask>,
|
||||
options?: TaskRunOptions & PollOptions
|
||||
options?: TaskRunOptions & PollOptions,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): Promise<RetrieveRunResult<TaskOutputHandle<TTask>>> {
|
||||
const handle = await trigger(id, payload, options);
|
||||
const handle = await trigger(id, payload, options, requestOptions);
|
||||
|
||||
return runs.poll(handle);
|
||||
return runs.poll(handle, options, requestOptions);
|
||||
}
|
||||
|
||||
export async function batchTrigger<TTask extends AnyTask>(
|
||||
id: TaskIdentifier<TTask>,
|
||||
items: Array<BatchItem<TaskPayload<TTask>>>
|
||||
items: Array<BatchItem<TaskPayload<TTask>>>,
|
||||
requestOptions?: ApiRequestOptions
|
||||
): Promise<TaskBatchOutputHandle<TTask>> {
|
||||
const apiClient = apiClientManager.client;
|
||||
|
||||
@@ -870,26 +926,51 @@ export async function batchTrigger<TTask extends AnyTask>(
|
||||
throw apiClientMissingError();
|
||||
}
|
||||
|
||||
const response = await apiClient.batchTriggerTask(id, {
|
||||
items: await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const payloadPacket = await stringifyIO(item.payload);
|
||||
const response = await apiClient.batchTriggerTask(
|
||||
id,
|
||||
{
|
||||
items: await Promise.all(
|
||||
items.map(async (item) => {
|
||||
const payloadPacket = await stringifyIO(item.payload);
|
||||
|
||||
return {
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: item.options?.queue,
|
||||
concurrencyKey: item.options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: item.options?.idempotencyKey,
|
||||
delay: item.options?.delay,
|
||||
ttl: item.options?.ttl,
|
||||
},
|
||||
};
|
||||
})
|
||||
),
|
||||
});
|
||||
return {
|
||||
payload: payloadPacket.data,
|
||||
options: {
|
||||
queue: item.options?.queue,
|
||||
concurrencyKey: item.options?.concurrencyKey,
|
||||
test: taskContext.ctx?.run.isTest,
|
||||
payloadType: payloadPacket.dataType,
|
||||
idempotencyKey: await makeKey(item.options?.idempotencyKey),
|
||||
delay: item.options?.delay,
|
||||
ttl: item.options?.ttl,
|
||||
maxAttempts: item.options?.maxAttempts,
|
||||
},
|
||||
};
|
||||
})
|
||||
),
|
||||
},
|
||||
{ spanParentAsLink: true },
|
||||
{
|
||||
name: `tasks.batchTrigger()`,
|
||||
tracer,
|
||||
icon: "trigger",
|
||||
attributes: {
|
||||
[SEMATTRS_MESSAGING_OPERATION]: "publish",
|
||||
["messaging.client_id"]: taskContext.worker?.id,
|
||||
[SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev",
|
||||
...accessoryAttributes({
|
||||
items: [
|
||||
{
|
||||
text: id,
|
||||
variant: "normal",
|
||||
},
|
||||
],
|
||||
style: "codepath",
|
||||
}),
|
||||
},
|
||||
...requestOptions,
|
||||
}
|
||||
);
|
||||
|
||||
const handle = {
|
||||
batchId: response.batchId,
|
||||
@@ -968,3 +1049,17 @@ export function apiClientMissingError() {
|
||||
|
||||
return `Unknown error`;
|
||||
}
|
||||
|
||||
async function makeKey(
|
||||
idempotencyKey?: IdempotencyKey | string | string[]
|
||||
): Promise<IdempotencyKey | undefined> {
|
||||
if (!idempotencyKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isIdempotencyKey(idempotencyKey)) {
|
||||
return idempotencyKey;
|
||||
}
|
||||
|
||||
return await idempotencyKeys.create(idempotencyKey, { scope: "global" });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { logger, type HandleErrorFunction } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const handleError: HandleErrorFunction = async (payload, error, { ctx, retry }) => {
|
||||
logger.log("handling error", { error });
|
||||
export const handleError: HandleErrorFunction = async (
|
||||
payload,
|
||||
error,
|
||||
{ ctx, retry, retryAt, retryDelayInMs }
|
||||
) => {
|
||||
logger.log("handling error", { error, retry, retryAt, retryDelayInMs });
|
||||
};
|
||||
|
||||
@@ -5,12 +5,27 @@ import { firstScheduledTask } from "./trigger/scheduled";
|
||||
import { simpleChildTask } from "./trigger/subtasks";
|
||||
import { taskThatErrors } from "./trigger/retries";
|
||||
import { unfriendlyIdTask } from "./trigger/other";
|
||||
import { spamRateLimiter } from "./trigger/retries";
|
||||
|
||||
dotenv.config();
|
||||
|
||||
async function doSpamRateLimiter() {
|
||||
// Trigger 10 runs
|
||||
await spamRateLimiter.batchTrigger(
|
||||
Array.from({ length: 10 }, (_, i) => ({ payload: { runId: "run_pxxs52j3geik6cj6j8piq" } }))
|
||||
);
|
||||
}
|
||||
|
||||
// doSpamRateLimiter().catch(console.error);
|
||||
|
||||
async function doEnvVars() {
|
||||
configure({
|
||||
secretKey: process.env.TRIGGER_ACCESS_TOKEN,
|
||||
requestOptions: {
|
||||
retry: {
|
||||
maxAttempts: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response1 = await envvars.upload("yubjwjsfkxnylobaqvqz", "dev", {
|
||||
@@ -80,11 +95,18 @@ async function doRuns() {
|
||||
async function doListRuns() {
|
||||
let pageCount = 0;
|
||||
|
||||
let page = await runs.list({
|
||||
limit: 100,
|
||||
});
|
||||
let page = await runs.list(
|
||||
{
|
||||
limit: 100,
|
||||
},
|
||||
{
|
||||
retry: {
|
||||
maxAttempts: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`run page #${++pageCount}`);
|
||||
console.log(`run page #${++pageCount}, with ${page.data.length} runs`);
|
||||
|
||||
// Convenience methods are provided for manually paginating:
|
||||
while (page.hasNextPage()) {
|
||||
@@ -236,7 +258,7 @@ async function doTriggerUnfriendlyTaskId() {
|
||||
}
|
||||
|
||||
// doRuns().catch(console.error);
|
||||
// doListRuns().catch(console.error);
|
||||
doListRuns().catch(console.error);
|
||||
// doScheduleLists().catch(console.error);
|
||||
// doSchedules().catch(console.error);
|
||||
// doEnvVars().catch(console.error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { logger, task, wait } from "@trigger.dev/sdk/v3";
|
||||
import { AbortTaskRunError } from "@trigger.dev/core/v3";
|
||||
import { idempotencyKeys, logger, task, wait } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const idempotencyKeyParent = task({
|
||||
id: "idempotency-key-parent",
|
||||
@@ -11,7 +12,7 @@ export const idempotencyKeyParent = task({
|
||||
forceError: true,
|
||||
},
|
||||
{
|
||||
idempotencyKey: payload.key,
|
||||
idempotencyKey: await idempotencyKeys.create(payload.key),
|
||||
}
|
||||
);
|
||||
|
||||
@@ -21,6 +22,16 @@ export const idempotencyKeyParent = task({
|
||||
logger.error("Child task error", { error: childTaskResponse.error });
|
||||
}
|
||||
|
||||
await idempotencyKeyChild2.triggerAndWait(
|
||||
{
|
||||
key: payload.key,
|
||||
forceError: false,
|
||||
},
|
||||
{
|
||||
idempotencyKey: await idempotencyKeys.create(payload.key),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
key: payload.key,
|
||||
childTaskResponse,
|
||||
@@ -33,32 +44,45 @@ export const idempotencyKeyChild = task({
|
||||
run: async (payload: { forceError: boolean; key: string }) => {
|
||||
console.log("Hello from idempotency-key-child", payload.key);
|
||||
|
||||
await wait.for({ seconds: 5 });
|
||||
await wait.for({ seconds: 2 });
|
||||
|
||||
if (payload.forceError) {
|
||||
throw new Error("This is a forced error in idempotency-key-child");
|
||||
throw new AbortTaskRunError("This is a forced error in idempotency-key-child");
|
||||
}
|
||||
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
export const idempotencyKeyChild2 = task({
|
||||
id: "idempotency-key-child-2",
|
||||
run: async (payload: { forceError: boolean; key: string }) => {
|
||||
console.log("Hello from idempotency-key-child-2", payload.key);
|
||||
|
||||
await wait.for({ seconds: 2 });
|
||||
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
export const idempotencyKeyBatchParent = task({
|
||||
id: "idempotency-key-batch-parent",
|
||||
run: async (payload: { keyPrefix: string; itemCount: number }) => {
|
||||
console.log("Hello from idempotency-key-batch-parent");
|
||||
|
||||
const childTaskResponse = await idempotencyKeyBatchChild.batchTriggerAndWait(
|
||||
Array.from({ length: payload.itemCount }).map((_, index) => ({
|
||||
payload: {
|
||||
key: `${payload.keyPrefix}-${index}`,
|
||||
forceError: index % 2 === 0,
|
||||
waitSeconds: 5 * index,
|
||||
},
|
||||
options: {
|
||||
idempotencyKey: `${payload.keyPrefix}-${index}`,
|
||||
},
|
||||
}))
|
||||
await Promise.all(
|
||||
Array.from({ length: payload.itemCount }).map(async (_, index) => ({
|
||||
payload: {
|
||||
key: `${payload.keyPrefix}-${index}`,
|
||||
forceError: index % 2 === 0,
|
||||
waitSeconds: 5 * index,
|
||||
},
|
||||
options: {
|
||||
idempotencyKey: await idempotencyKeys.create([payload.keyPrefix, String(index)]),
|
||||
},
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -82,3 +106,41 @@ export const idempotencyKeyBatchChild = task({
|
||||
return payload;
|
||||
},
|
||||
});
|
||||
|
||||
export const idempotencyKeyParentUsage = task({
|
||||
id: "idempotency-key-parent-usage",
|
||||
run: async (payload: { key: string; scope: "run" | "attempt" | "global" }, { ctx }) => {
|
||||
console.log(`Hello from idempotency-key-parent-usage, attempt #${ctx.attempt.number}`);
|
||||
|
||||
const idempotencyKey = await idempotencyKeys.create(payload.key, { scope: payload.scope });
|
||||
|
||||
console.log(`Generated idempotency key: ${idempotencyKey}`);
|
||||
|
||||
const childTaskResponse = await idempotencyKeyChild.triggerAndWait(
|
||||
{
|
||||
key: idempotencyKey,
|
||||
forceError: true,
|
||||
},
|
||||
{
|
||||
idempotencyKey,
|
||||
}
|
||||
);
|
||||
|
||||
if (childTaskResponse.ok) {
|
||||
logger.log("Child task response", { output: childTaskResponse.output });
|
||||
} else {
|
||||
logger.error("Child task error", { error: childTaskResponse.error });
|
||||
|
||||
if (ctx.attempt.number > 1) {
|
||||
throw new AbortTaskRunError("Child task failed on retry, exiting parent task");
|
||||
} else {
|
||||
throw new Error("Child task failed");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: payload.key,
|
||||
childTaskResponse,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { logger, retry, task, wait } from "@trigger.dev/sdk/v3";
|
||||
import { logger, retry, runs, task, wait } from "@trigger.dev/sdk/v3";
|
||||
import { cache } from "./utils/cache";
|
||||
import { interceptor } from "./utils/interceptor";
|
||||
import { join } from "node:path";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
|
||||
export const taskWithRetries = task({
|
||||
id: "task-with-retries",
|
||||
@@ -131,3 +133,128 @@ export const taskWithFetchRetries = task({
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const taskWithRateLimitRetries = task({
|
||||
id: "task-with-rate-limit-retries",
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
factor: 1.8,
|
||||
},
|
||||
run: async (payload: { runId: string }, { ctx }) => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const { response } = await runs.retrieve(payload.runId).withResponse();
|
||||
|
||||
const limit = response.headers.get("x-ratelimit-limit");
|
||||
const remaining = response.headers.get("x-ratelimit-remaining");
|
||||
const reset = response.headers.get("x-ratelimit-reset");
|
||||
|
||||
console.log(
|
||||
`Rate limit: ${remaining}/${limit} remaining. Reset at ${new Date(
|
||||
parseInt(reset!, 10)
|
||||
).toISOString()}`
|
||||
);
|
||||
|
||||
if (remaining === "0") {
|
||||
// break out of the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Rate limit almost breached, triggering child task to test rate limit.");
|
||||
|
||||
// Now we want to trigger a subtask to test the rate limit
|
||||
await childTaskWithRateLimitRetries.trigger({ runId: payload.runId });
|
||||
},
|
||||
});
|
||||
|
||||
export const childTaskWithRateLimitRetries = task({
|
||||
id: "child-task-with-rate-limit-retries",
|
||||
run: async (payload: { runId: string }, { ctx }) => {
|
||||
return runs.retrieve(payload.runId);
|
||||
},
|
||||
});
|
||||
|
||||
export const taskRetriesAfterRateLimitError = task({
|
||||
id: "task-retries-after-rate-limit-error",
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
factor: 1.8,
|
||||
},
|
||||
run: async (payload: { runId: string }, { ctx }) => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const { response } = await runs.retrieve(payload.runId).withResponse();
|
||||
|
||||
const limit = response.headers.get("x-ratelimit-limit");
|
||||
const remaining = response.headers.get("x-ratelimit-remaining");
|
||||
const reset = response.headers.get("x-ratelimit-reset");
|
||||
|
||||
console.log(
|
||||
`Rate limit: ${remaining}/${limit} remaining. Reset at ${new Date(
|
||||
parseInt(reset!, 10)
|
||||
).toISOString()}`
|
||||
);
|
||||
|
||||
if (remaining === "0") {
|
||||
// break out of the loop
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Rate limit almost breached, triggering child task to test rate limit.");
|
||||
|
||||
// Now we are going to cause a rate limit error to test the retry mechanism
|
||||
await runs.retrieve(payload.runId, {
|
||||
retry: {
|
||||
maxAttempts: 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const spamRateLimiter = task({
|
||||
id: "spam-rate-limiter",
|
||||
retry: {
|
||||
maxAttempts: 5,
|
||||
minTimeoutInMs: 500,
|
||||
maxTimeoutInMs: 30_000,
|
||||
factor: 1.8,
|
||||
},
|
||||
run: async (payload: { runId: string }, { ctx }) => {
|
||||
const requestStats = {
|
||||
total: 0,
|
||||
};
|
||||
|
||||
while (requestStats.total < 100) {
|
||||
const { response } = await runs.retrieve(payload.runId).withResponse();
|
||||
|
||||
const remaining = response.headers.get("x-ratelimit-remaining");
|
||||
|
||||
await logRequest("runs/spam-run-test-3", ctx.run.id, remaining!);
|
||||
|
||||
requestStats.total++;
|
||||
}
|
||||
|
||||
return requestStats;
|
||||
},
|
||||
});
|
||||
|
||||
// Write out a log entry for the request
|
||||
async function logRequest(dir: string, file: string, remaining: string, ts: Date = new Date()) {
|
||||
const log = {
|
||||
ts,
|
||||
remaining,
|
||||
};
|
||||
|
||||
const $dir = join(process.cwd(), dir);
|
||||
|
||||
// Create the dir if it doesn't exist
|
||||
await mkdir($dir, { recursive: true });
|
||||
|
||||
// Make sure to swap '/path/to/request/logs/' with an actual directory path
|
||||
const filePath = join($dir, `${file}-${log.ts.toISOString()}.json`);
|
||||
await writeFile(filePath, JSON.stringify(log, null, 2));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { task, tasks, runs, logger, schedules, envvars } from "@trigger.dev/sdk/v3";
|
||||
|
||||
export const sdkUsage = task({
|
||||
id: "sdk-usage",
|
||||
run: async (payload: any, { ctx }) => {
|
||||
const $runs = await runs.list({
|
||||
limit: 10,
|
||||
status: "COMPLETED",
|
||||
});
|
||||
|
||||
const $firstRun = await runs.retrieve($runs.data[0].id);
|
||||
|
||||
const handle = await tasks.trigger<typeof sdkChild>("sdk-child", {
|
||||
run: $firstRun,
|
||||
});
|
||||
|
||||
await tasks.triggerAndPoll<typeof sdkChild>("sdk-child", {
|
||||
handle,
|
||||
});
|
||||
|
||||
const replayedRun = await runs.replay($firstRun.id);
|
||||
|
||||
await runs.cancel(replayedRun.id);
|
||||
|
||||
const delayed = await tasks.trigger<typeof sdkChild>(
|
||||
"sdk-child",
|
||||
{
|
||||
delay: "1h",
|
||||
},
|
||||
{
|
||||
delay: "1h",
|
||||
}
|
||||
);
|
||||
|
||||
await runs.reschedule(delayed.id, {
|
||||
delay: "1m",
|
||||
});
|
||||
|
||||
for await (const run of runs.list({
|
||||
limit: 10,
|
||||
period: "1d",
|
||||
})) {
|
||||
logger.log(run.id, { run });
|
||||
}
|
||||
|
||||
const batchHandle = await sdkChild.batchTrigger([
|
||||
{
|
||||
payload: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const waitResult = await sdkChild.triggerAndWait({
|
||||
payload: {},
|
||||
});
|
||||
|
||||
await sdkChild.batchTriggerAndWait([
|
||||
{
|
||||
payload: {},
|
||||
},
|
||||
]);
|
||||
|
||||
await tasks.batchTrigger<typeof sdkChild>("sdk-child", [
|
||||
{
|
||||
payload: {},
|
||||
},
|
||||
]);
|
||||
|
||||
const schedule = await schedules.create({
|
||||
cron: "0 0 * * *", // every day at midnight
|
||||
deduplicationKey: ctx.run.id,
|
||||
externalId: ctx.run.id,
|
||||
task: "sdk-schedule",
|
||||
});
|
||||
|
||||
await schedules.retrieve(schedule.id);
|
||||
|
||||
await schedules.del(schedule.id);
|
||||
|
||||
await envvars.upload({
|
||||
variables: {
|
||||
INSIDE_RUN: "true",
|
||||
},
|
||||
override: true,
|
||||
});
|
||||
|
||||
await envvars.list({
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
},
|
||||
});
|
||||
|
||||
await envvars.create(
|
||||
{
|
||||
name: "INSIDE_RUN_2",
|
||||
value: "true",
|
||||
},
|
||||
{
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await envvars.retrieve("INSIDE_RUN_2");
|
||||
|
||||
await envvars.update(
|
||||
"INSIDE_RUN_2",
|
||||
{
|
||||
value: "false",
|
||||
},
|
||||
{
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
},
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const sdkChild = task({
|
||||
id: "sdk-child",
|
||||
run: async (payload: any) => {},
|
||||
});
|
||||
|
||||
export const sdkSchedule = schedules.task({
|
||||
id: "sdk-schedule",
|
||||
run: async (payload: any) => {},
|
||||
});
|
||||
@@ -19,7 +19,7 @@ export const simpleChildTask = task({
|
||||
run: async (payload: { message: string }, { ctx }) => {
|
||||
logger.log("Simple child task payload", { payload, ctx });
|
||||
|
||||
await wait.for({ seconds: 6 });
|
||||
await wait.for({ seconds: 10 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -137,6 +137,19 @@ export const taskWithNoPayload = task({
|
||||
},
|
||||
});
|
||||
|
||||
export const simpleTaskParentWithSubtasks = task({
|
||||
id: "simple-task-parent-with-subtask",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await simpleChildTask.triggerAndWait({ message: `${message} - 1` });
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const deeplyNestedTaskParent = task({
|
||||
id: "deeply-nested-task-parent",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
@@ -151,6 +164,8 @@ export const deeplyNestedTaskParent = task({
|
||||
export const deeplyNestedTaskChild = task({
|
||||
id: "deeply-nested-task-child",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
await deeplyNestedTaskGrandchild.triggerAndWait({ message: `${message} - 2` });
|
||||
|
||||
return {
|
||||
@@ -162,7 +177,9 @@ export const deeplyNestedTaskChild = task({
|
||||
export const deeplyNestedTaskGrandchild = task({
|
||||
id: "deeply-nested-task-grandchild",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await deeplyNestedTaskGreatGrandchild.triggerAndWait({ message: `${message} - 3` });
|
||||
await deeplyNestedTaskGreatGrandchild.batchTriggerAndWait(
|
||||
Array.from({ length: 100 }, (_, i) => ({ payload: { message: `${message} - ${i}` } }))
|
||||
);
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
@@ -173,6 +190,78 @@ export const deeplyNestedTaskGrandchild = task({
|
||||
export const deeplyNestedTaskGreatGrandchild = task({
|
||||
id: "deeply-nested-task-great-grandchild",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dependencyCancellationParent = task({
|
||||
id: "dependency-cancellation-parent",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
const handle = await dependencyCancellationChild.triggerAndWait({ message: `${message} - 1` });
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dependencyCancellationChild = task({
|
||||
id: "dependency-cancellation-child",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await dependencyCancellationGrandchild.triggerAndWait({ message: `${message} - 2` });
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const dependencyCancellationGrandchild = task({
|
||||
id: "dependency-cancellation-grandchild",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await wait.for({ seconds: 30 });
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const batchDependencyCancellationParent = task({
|
||||
id: "batch-dependency-cancellation-parent",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
const handle = await batchDependencyCancellationChild.batchTriggerAndWait(
|
||||
Array.from({ length: 10 }, (_, i) => ({ payload: { message: `${message} - ${i}` } }))
|
||||
);
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const batchDependencyCancellationChild = task({
|
||||
id: "batch-dependency-cancellation-child",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
const handle = await batchDependencyCancellationGrandChild.batchTriggerAndWait(
|
||||
Array.from({ length: 10 }, (_, i) => ({ payload: { message: `${message} - ${i}` } }))
|
||||
);
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const batchDependencyCancellationGrandChild = task({
|
||||
id: "batch-dependency-cancellation-grandchild",
|
||||
run: async ({ message = "test" }: { message?: string }) => {
|
||||
await wait.for({ seconds: 30 });
|
||||
|
||||
return {
|
||||
hello: "world",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user