From 7ec44c2f5b6a5d66f3c713e492815b8d38c6814d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Fri, 22 Nov 2024 10:40:39 +0000 Subject: [PATCH 01/44] WIP batch trigger v2 --- .github/copilot-instructions.md | 1 + apps/webapp/app/env.server.ts | 2 + .../routes/api.v1.tasks.$taskId.trigger.ts | 1 + apps/webapp/app/routes/api.v1.tasks.batch.ts | 131 +++++ .../routeBuilders/apiBuilder.server.ts | 21 +- apps/webapp/app/services/worker.server.ts | 19 + .../app/utils/idempotencyKeys.server.ts | 39 ++ .../app/v3/marqs/devQueueConsumer.server.ts | 12 - .../v3/marqs/sharedQueueConsumer.server.ts | 6 +- apps/webapp/app/v3/queueSizeLimits.server.ts | 6 +- apps/webapp/app/v3/r2.server.ts | 93 ++- .../v3/services/batchTriggerTask.server.ts | 3 +- .../app/v3/services/batchTriggerV2.server.ts | 533 ++++++++++++++++++ .../services/createTaskRunAttempt.server.ts | 6 +- .../app/v3/services/triggerTask.server.ts | 38 +- .../migration.sql | 16 + .../migration.sql | 5 + .../migration.sql | 3 + .../migration.sql | 2 + .../database/prisma/schema.prisma | 56 +- packages/core/src/v3/apiClient/index.ts | 57 +- packages/core/src/v3/schemas/api.ts | 49 ++ packages/core/src/v3/types/tasks.ts | 25 +- packages/trigger-sdk/package.json | 2 +- packages/trigger-sdk/src/v3/shared.ts | 42 +- references/v3-catalog/src/trigger/batch.ts | 61 +- 26 files changed, 1127 insertions(+), 102 deletions(-) create mode 100644 .github/copilot-instructions.md create mode 100644 apps/webapp/app/routes/api.v1.tasks.batch.ts create mode 100644 apps/webapp/app/utils/idempotencyKeys.server.ts create mode 100644 apps/webapp/app/v3/services/batchTriggerV2.server.ts create mode 100644 internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20241120174034_add_idempotency_key_expires_at_columns/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20241121114454_add_payload_columns_to_batch_task_run/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20241121135006_add_options_to_batch_task_run/migration.sql diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000000..2beb7606fa --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +This is the repo for Trigger.dev, a background jobs platform written in TypeScript. Our webapp at apps/webapp is a Remix 2.1 app that uses Node.js v20. Our SDK is an isomorphic TypeScript SDK at packages/trigger-sdk. Always prefer using isomorphic code like fetch, ReadableStream, etc. instead of Node.js specific code. Our tests are all vitest. We use prisma in internal-packages/database for our database interactions using PostgreSQL. For TypeScript, we usually use types over interfaces. We use zod a lot in packages/core and in the webapp. Avoid enums. Use strict mode. No default exports, use function declarations. diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index ee2e2713d2..bc226a6720 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -233,10 +233,12 @@ const EnvironmentSchema = z.object({ MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000), TASK_PAYLOAD_OFFLOAD_THRESHOLD: z.coerce.number().int().default(524_288), // 512KB TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(3_145_728), // 3MB + BATCH_TASK_PAYLOAD_MAXIMUM_SIZE: z.coerce.number().int().default(1_000_000), // 1MB TASK_RUN_METADATA_MAXIMUM_SIZE: z.coerce.number().int().default(4_096), // 4KB MAXIMUM_DEV_QUEUE_SIZE: z.coerce.number().int().optional(), MAXIMUM_DEPLOYED_QUEUE_SIZE: z.coerce.number().int().optional(), + MAX_BATCH_V2_TRIGGER_ITEMS: z.coerce.number().int().default(500), }); export type Environment = z.infer; diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 8e0df325ac..1eff3a9561 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -15,6 +15,7 @@ const ParamsSchema = z.object({ export const HeadersSchema = z.object({ "idempotency-key": z.string().nullish(), + "idempotency-key-ttl": z.string().nullish(), "trigger-version": z.string().nullish(), "x-trigger-span-parent-as-link": z.coerce.number().nullish(), "x-trigger-worker": z.string().nullish(), diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts new file mode 100644 index 0000000000..64ffc8bac4 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -0,0 +1,131 @@ +import { json } from "@remix-run/server-runtime"; +import { + BatchTriggerTaskResponse, + BatchTriggerTaskV2RequestBody, + BatchTriggerTaskV2Response, + generateJWT, +} from "@trigger.dev/core/v3"; +import { env } from "~/env.server"; +import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { HeadersSchema } from "./api.v1.tasks.$taskId.trigger"; +import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; +import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; +import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; + +const { action, loader } = createActionApiRoute( + { + headers: HeadersSchema, + body: BatchTriggerTaskV2RequestBody, + allowJWT: true, + maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE, + authorization: { + action: "write", + resource: (_, __, ___, body) => ({ + tasks: Array.from(new Set(body.items.map((i) => i.task))), + }), + superScopes: ["write:tasks", "admin"], + }, + corsStrategy: "all", + }, + async ({ body, headers, params, authentication }) => { + if (!body.items.length) { + return json({ error: "Batch cannot be triggered with no items" }, { status: 400 }); + } + + // Check the there are fewer than MAX_BATCH_V2_TRIGGER_ITEMS items + if (body.items.length > env.MAX_BATCH_V2_TRIGGER_ITEMS) { + return json( + { + error: `Batch size of ${body.items.length} is too large. Maximum allowed batch size is ${env.MAX_BATCH_V2_TRIGGER_ITEMS}.`, + }, + { status: 400 } + ); + } + + const { + "idempotency-key": idempotencyKey, + "idempotency-key-ttl": idempotencyKeyTTL, + "trigger-version": triggerVersion, + "x-trigger-span-parent-as-link": spanParentAsLink, + "x-trigger-worker": isFromWorker, + "x-trigger-client": triggerClient, + traceparent, + tracestate, + } = headers; + + const traceContext = + traceparent && isFromWorker // If the request is from a worker, we should pass the trace context + ? { traceparent, tracestate } + : undefined; + + const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL); + + const service = new BatchTriggerV2Service(); + + try { + const batch = await service.call(authentication.environment, body, { + idempotencyKey: idempotencyKey ?? undefined, + idempotencyKeyExpiresAt, + triggerVersion: triggerVersion ?? undefined, + traceContext, + spanParentAsLink: spanParentAsLink === 1, + }); + + const $responseHeaders = await responseHeaders( + batch, + authentication.environment, + triggerClient + ); + + return json(batch, { status: 202, headers: $responseHeaders }); + } catch (error) { + if (error instanceof ServiceValidationError) { + return json({ error: error.message }, { status: 422 }); + } else if (error instanceof OutOfEntitlementError) { + return json({ error: error.message }, { status: 422 }); + } else if (error instanceof Error) { + return json({ error: error.message }, { status: 500 }); + } + + return json({ error: "Something went wrong" }, { status: 500 }); + } + } +); + +async function responseHeaders( + batch: BatchTriggerTaskV2Response, + environment: AuthenticatedEnvironment, + triggerClient?: string | null +): Promise> { + const claimsHeader = JSON.stringify({ + sub: environment.id, + pub: true, + }); + + if (triggerClient === "browser") { + const claims = { + sub: environment.id, + pub: true, + scopes: [`read:batch:${batch.id}`].concat(batch.runs.map((r) => `read:runs:${r.id}`)), + }; + + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: claims, + expirationTime: "1h", + }); + + return { + "x-trigger-jwt-claims": claimsHeader, + "x-trigger-jwt": jwt, + }; + } + + return { + "x-trigger-jwt-claims": claimsHeader, + }; +} + +export { action, loader }; diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 27358e5906..d15526d45c 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -346,7 +346,24 @@ type ApiKeyActionRouteBuilderOptions< TSearchParamsSchema extends z.AnyZodObject | undefined = undefined, THeadersSchema extends z.AnyZodObject | undefined = undefined, TBodySchema extends z.AnyZodObject | undefined = undefined -> = ApiKeyRouteBuilderOptions & { +> = { + params?: TParamsSchema; + searchParams?: TSearchParamsSchema; + headers?: THeadersSchema; + allowJWT?: boolean; + corsStrategy?: "all" | "none"; + authorization?: { + action: AuthorizationAction; + resource: ( + params: TParamsSchema extends z.AnyZodObject ? z.infer : undefined, + searchParams: TSearchParamsSchema extends z.AnyZodObject + ? z.infer + : undefined, + headers: THeadersSchema extends z.AnyZodObject ? z.infer : undefined, + body: TBodySchema extends z.AnyZodObject ? z.infer : undefined + ) => AuthorizationResources; + superScopes?: string[]; + }; maxContentLength?: number; body?: TBodySchema; }; @@ -517,7 +534,7 @@ export function createActionApiRoute< if (authorization) { const { action, resource, superScopes } = authorization; - const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders); + const $resource = resource(parsedParams, parsedSearchParams, parsedHeaders, parsedBody); logger.debug("Checking authorization", { action, diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index 41aa63fcb6..0e996ecb0e 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -55,6 +55,7 @@ import { CancelDevSessionRunsServiceOptions, } from "~/v3/services/cancelDevSessionRuns.server"; import { logger } from "./logger.server"; +import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server"; const workerCatalog = { indexEndpoint: z.object({ @@ -197,6 +198,11 @@ const workerCatalog = { attemptId: z.string(), }), "v3.cancelDevSessionRuns": CancelDevSessionRunsServiceOptions, + "v3.processBatchTaskRun": z.object({ + batchId: z.string(), + currentIndex: z.number().int(), + attemptCount: z.number().int(), + }), }; const executionWorkerCatalog = { @@ -727,6 +733,19 @@ function getWorkerQueue() { return await service.call(payload); }, }, + "v3.processBatchTaskRun": { + priority: 0, + maxAttempts: 5, + handler: async (payload, job) => { + const service = new BatchTriggerV2Service(); + + await service.processBatchTaskRun( + payload.batchId, + payload.currentIndex, + payload.attemptCount + ); + }, + }, }, }); } diff --git a/apps/webapp/app/utils/idempotencyKeys.server.ts b/apps/webapp/app/utils/idempotencyKeys.server.ts new file mode 100644 index 0000000000..ea88bfbfe8 --- /dev/null +++ b/apps/webapp/app/utils/idempotencyKeys.server.ts @@ -0,0 +1,39 @@ +/** + * Resolve the TTL for an idempotency key. + * + * The TTL format is a string like "5m", "1h", "7d" + * + * @param ttl The TTL string + * @returns The date when the key will expire + * @throws If the TTL string is invalid + */ +export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | undefined { + if (!ttl) { + return undefined; + } + + const match = ttl.match(/^(\d+)([smhd])$/); + + if (!match) { + return; + } + + const [, value, unit] = match; + + const now = new Date(); + + switch (unit) { + case "s": + now.setSeconds(now.getSeconds() + parseInt(value, 10)); + break; + case "m": + now.setMinutes(now.getMinutes() + parseInt(value, 10)); + break; + case "h": + now.setHours(now.getHours() + parseInt(value, 10)); + break; + case "d": + now.setDate(now.getDate() + parseInt(value, 10)); + break; + } +} diff --git a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts index 0dc162c638..38a515954a 100644 --- a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts @@ -384,18 +384,6 @@ export class DevQueueConsumer { backgroundTask.maxDurationInSeconds ), }, - include: { - attempts: { - take: 1, - orderBy: { number: "desc" }, - }, - tags: true, - batchItems: { - include: { - batchTaskRun: true, - }, - }, - }, }); if (!lockedTaskRun) { diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 3a0e17cb13..3c60fa88ca 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -1076,7 +1076,11 @@ class SharedQueueTasks { tags: true, batchItems: { include: { - batchTaskRun: true, + batchTaskRun: { + select: { + friendlyId: true, + }, + }, }, }, }, diff --git a/apps/webapp/app/v3/queueSizeLimits.server.ts b/apps/webapp/app/v3/queueSizeLimits.server.ts index 152e97c0e8..75f6b15d47 100644 --- a/apps/webapp/app/v3/queueSizeLimits.server.ts +++ b/apps/webapp/app/v3/queueSizeLimits.server.ts @@ -10,7 +10,8 @@ export type QueueSizeGuardResult = { export async function guardQueueSizeLimitsForEnv( environment: AuthenticatedEnvironment, - marqs?: MarQS + marqs?: MarQS, + itemsToAdd: number = 1 ): Promise { const maximumSize = getMaximumSizeForEnvironment(environment); @@ -23,9 +24,10 @@ export async function guardQueueSizeLimitsForEnv( } const queueSize = await marqs.lengthOfEnvQueue(environment); + const projectedSize = queueSize + itemsToAdd; return { - isWithinLimits: queueSize < maximumSize, + isWithinLimits: projectedSize <= maximumSize, maximumSize, queueSize, }; diff --git a/apps/webapp/app/v3/r2.server.ts b/apps/webapp/app/v3/r2.server.ts index 82564f8329..debf18a83e 100644 --- a/apps/webapp/app/v3/r2.server.ts +++ b/apps/webapp/app/v3/r2.server.ts @@ -4,6 +4,7 @@ import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { singleton } from "~/utils/singleton"; import { startActiveSpan } from "./tracer.server"; +import { IOPacket } from "@trigger.dev/core/v3"; export const r2 = singleton("r2", initializeR2); @@ -18,13 +19,13 @@ function initializeR2() { }); } -export async function uploadToObjectStore( +export async function uploadPacketToObjectStore( filename: string, - data: string, + data: ReadableStream | string, contentType: string, environment: AuthenticatedEnvironment ): Promise { - return await startActiveSpan("uploadToObjectStore()", async (span) => { + return await startActiveSpan("uploadPacketToObjectStore()", async (span) => { if (!r2) { throw new Error("Object store credentials are not set"); } @@ -60,6 +61,92 @@ export async function uploadToObjectStore( }); } +export async function downloadPacketFromObjectStore( + packet: IOPacket, + environment: AuthenticatedEnvironment +): Promise { + if (packet.dataType !== "application/store") { + return packet; + } + + return await startActiveSpan("downloadPacketFromObjectStore()", async (span) => { + if (!r2) { + throw new Error("Object store credentials are not set"); + } + + if (!env.OBJECT_STORE_BASE_URL) { + throw new Error("Object store base URL is not set"); + } + + span.setAttributes({ + projectRef: environment.project.externalRef, + environmentSlug: environment.slug, + filename: packet.data, + }); + + const url = new URL(env.OBJECT_STORE_BASE_URL); + url.pathname = `/packets/${environment.project.externalRef}/${environment.slug}/${packet.data}`; + + logger.debug("Downloading from object store", { url: url.href }); + + const response = await r2.fetch(url.toString()); + + if (!response.ok) { + throw new Error(`Failed to download input from ${url}: ${response.statusText}`); + } + + const data = await response.text(); + + const rawPacket = { + data, + dataType: "application/json", + }; + + return rawPacket; + }); +} + +export async function uploadDataToObjectStore( + filename: string, + data: string, + contentType: string, + prefix?: string +): Promise { + return await startActiveSpan("uploadDataToObjectStore()", async (span) => { + if (!r2) { + throw new Error("Object store credentials are not set"); + } + + if (!env.OBJECT_STORE_BASE_URL) { + throw new Error("Object store base URL is not set"); + } + + span.setAttributes({ + prefix, + filename, + }); + + const url = new URL(env.OBJECT_STORE_BASE_URL); + url.pathname = `${prefix}/${filename}`; + + logger.debug("Uploading to object store", { url: url.href }); + + const response = await r2.fetch(url.toString(), { + method: "PUT", + headers: { + "Content-Type": contentType, + }, + body: data, + }); + + if (!response.ok) { + throw new Error(`Failed to upload data to ${url}: ${response.statusText}`); + } + + return url.href; + }); +} + export async function generatePresignedRequest( projectRef: string, envSlug: string, diff --git a/apps/webapp/app/v3/services/batchTriggerTask.server.ts b/apps/webapp/app/v3/services/batchTriggerTask.server.ts index fc5874bd4d..5dc1742e69 100644 --- a/apps/webapp/app/v3/services/batchTriggerTask.server.ts +++ b/apps/webapp/app/v3/services/batchTriggerTask.server.ts @@ -26,10 +26,9 @@ export class BatchTriggerTaskService extends BaseService { const existingBatch = options.idempotencyKey ? await this._prisma.batchTaskRun.findUnique({ where: { - runtimeEnvironmentId_taskIdentifier_idempotencyKey: { + runtimeEnvironmentId_idempotencyKey: { runtimeEnvironmentId: environment.id, idempotencyKey: options.idempotencyKey, - taskIdentifier: taskId, }, }, include: { diff --git a/apps/webapp/app/v3/services/batchTriggerV2.server.ts b/apps/webapp/app/v3/services/batchTriggerV2.server.ts new file mode 100644 index 0000000000..a6dfb08e4d --- /dev/null +++ b/apps/webapp/app/v3/services/batchTriggerV2.server.ts @@ -0,0 +1,533 @@ +import { + BatchTriggerTaskResponse, + BatchTriggerTaskV2RequestBody, + BatchTriggerTaskV2Response, + packetRequiresOffloading, + parsePacket, +} from "@trigger.dev/core/v3"; +import { $transaction, PrismaClientOrTransaction } from "~/db.server"; +import { env } from "~/env.server"; +import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { getEntitlement } from "~/services/platform.v3.server"; +import { workerQueue } from "~/services/worker.server"; +import { generateFriendlyId } from "../friendlyIdentifiers"; +import { marqs } from "../marqs/index.server"; +import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server"; +import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../r2.server"; +import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; +import { startActiveSpan } from "../tracer.server"; +import { BaseService, ServiceValidationError } from "./baseService.server"; +import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server"; +import { BatchTaskRun } from "@trigger.dev/database"; +import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server"; +import { logger } from "~/services/logger.server"; + +const PROCESSING_BATCH_SIZE = 50; + +export type BatchTriggerTaskServiceOptions = { + idempotencyKey?: string; + idempotencyKeyExpiresAt?: Date; + triggerVersion?: string; + traceContext?: Record; + spanParentAsLink?: boolean; +}; + +export class BatchTriggerV2Service extends BaseService { + public async call( + environment: AuthenticatedEnvironment, + body: BatchTriggerTaskV2RequestBody, + options: BatchTriggerTaskServiceOptions = {} + ): Promise { + return await this.traceWithEnv( + "call()", + environment, + async (span) => { + const existingBatch = options.idempotencyKey + ? await this._prisma.batchTaskRun.findUnique({ + where: { + runtimeEnvironmentId_idempotencyKey: { + runtimeEnvironmentId: environment.id, + idempotencyKey: options.idempotencyKey, + }, + }, + }) + : undefined; + + if (existingBatch) { + if ( + existingBatch.idempotencyKeyExpiresAt && + existingBatch.idempotencyKeyExpiresAt < new Date() + ) { + logger.debug("[BatchTriggerV2][call] Idempotency key has expired", { + idempotencyKey: options.idempotencyKey, + batch: { + id: existingBatch.id, + friendlyId: existingBatch.friendlyId, + runCount: existingBatch.runCount, + idempotencyKeyExpiresAt: existingBatch.idempotencyKeyExpiresAt, + idempotencyKey: existingBatch.idempotencyKey, + }, + }); + + // Update the existing batch to remove the idempotency key + await this._prisma.batchTaskRun.update({ + where: { id: existingBatch.id }, + data: { idempotencyKey: null }, + }); + + // Don't return, just continue with the batch trigger + } else { + span.setAttribute("batchId", existingBatch.friendlyId); + + return this.#respondWithExistingBatch(existingBatch, environment); + } + } + + const batchId = generateFriendlyId("batch"); + + span.setAttribute("batchId", batchId); + + 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("[BatchTriggerV2][call] Dependent attempt or run is in a terminal state", { + dependentAttempt: dependentAttempt, + batchId, + }); + + throw new ServiceValidationError( + "Cannot process batch as the parent run is already in a terminal state" + ); + } + + if (environment.type !== "DEVELOPMENT") { + const result = await getEntitlement(environment.organizationId); + if (result && result.hasAccess === false) { + throw new OutOfEntitlementError(); + } + } + + const idempotencyKeys = body.items.map((i) => i.options?.idempotencyKey).filter(Boolean); + + const cachedRuns = + idempotencyKeys.length > 0 + ? await this._prisma.taskRun.findMany({ + where: { + runtimeEnvironmentId: environment.id, + idempotencyKey: { + in: body.items.map((i) => i.options?.idempotencyKey).filter(Boolean), + }, + }, + select: { + friendlyId: true, + idempotencyKey: true, + idempotencyKeyExpiresAt: true, + }, + }) + : []; + + if (cachedRuns.length) { + logger.debug("[BatchTriggerV2][call] Found cached runs", { + cachedRuns, + batchId, + }); + } + + // Now we need to create an array of all the run IDs, in order + // If we have a cached run, that isn't expired, we should use that run ID + // If we have a cached run, that is expired, we should generate a new run ID and save that cached run ID to a set of expired run IDs + // If we don't have a cached run, we should generate a new run ID + const expiredRunIds = new Set(); + let cachedRunCount = 0; + + const runIds = body.items.map((item) => { + const cachedRun = cachedRuns.find( + (r) => r.idempotencyKey === item.options?.idempotencyKey + ); + + if (cachedRun) { + if ( + cachedRun.idempotencyKeyExpiresAt && + cachedRun.idempotencyKeyExpiresAt < new Date() + ) { + expiredRunIds.add(cachedRun.friendlyId); + + return generateFriendlyId("run"); + } + + cachedRunCount++; + + return cachedRun.friendlyId; + } + + return generateFriendlyId("run"); + }); + + // Calculate how many new runs we need to create + const newRunCount = body.items.length - cachedRunCount; + + if (newRunCount === 0) { + logger.debug("[BatchTriggerV2][call] All runs are cached", { + batchId, + }); + + return { + batchId, + runs: runIds, + }; + } + + const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs, newRunCount); + + logger.debug("Queue size guard result", { + newRunCount, + queueSizeGuard, + environment: { + id: environment.id, + type: environment.type, + organization: environment.organization, + project: environment.project, + }, + }); + + if (!queueSizeGuard.isWithinLimits) { + throw new ServiceValidationError( + `Cannot trigger ${newRunCount} tasks as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}` + ); + } + + // Expire the cached runs that are no longer valid + if (expiredRunIds.size) { + logger.debug("Expiring cached runs", { + expiredRunIds: Array.from(expiredRunIds), + batchId, + }); + + // TODO: is there a limit to the number of items we can update in a single query? + await this._prisma.taskRun.updateMany({ + where: { friendlyId: { in: Array.from(expiredRunIds) } }, + data: { idempotencyKey: null }, + }); + } + + // Upload to object store + const payloadPacket = await this.#handlePayloadPacket( + body.items, + `batch/${batchId}`, + environment + ); + + const batch = await $transaction(this._prisma, async (tx) => { + const batch = await tx.batchTaskRun.create({ + data: { + friendlyId: generateFriendlyId("batch"), + runtimeEnvironmentId: environment.id, + idempotencyKey: options.idempotencyKey, + idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, + dependentTaskAttemptId: dependentAttempt?.id, + runCount: body.items.length, + runIds, + payload: payloadPacket.data, + payloadType: payloadPacket.dataType, + options, + }, + }); + + await this.#enqueueBatchTaskRun(batch.id, 0, 0, tx); + + return batch; + }); + + if (!batch) { + throw new Error("Failed to create batch"); + } + + return { + id: batch.friendlyId, + isCached: false, + idempotencyKey: batch.idempotencyKey ?? undefined, + }; + } + ); + } + + async #respondWithExistingBatch( + batch: BatchTaskRun, + environment: AuthenticatedEnvironment + ): Promise { + // Resolve the payload + const payloadPacket = await downloadPacketFromObjectStore( + { + data: batch.payload ?? undefined, + dataType: batch.payloadType, + }, + environment + ); + + const payload = await parsePacket(payloadPacket).then( + (p) => p as BatchTriggerTaskV2RequestBody["items"] + ); + + const runs = batch.runIds.map((id, index) => { + const item = payload[index]; + + return { + id, + taskIdentifier: item.task, + isCached: true, + idempotencyKey: item.options?.idempotencyKey ?? undefined, + }; + }); + + return { + id: batch.friendlyId, + idempotencyKey: batch.idempotencyKey ?? undefined, + isCached: true, + runs, + }; + } + + async processBatchTaskRun(batchId: string, currentIndex: number, attemptCount: number) { + logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch", { + batchId, + currentIndex, + attemptCount, + }); + + const $attemptCount = attemptCount + 1; + + const batch = await this._prisma.batchTaskRun.findFirst({ + where: { id: batchId }, + include: { + runtimeEnvironment: { + include: { + project: true, + organization: true, + }, + }, + }, + }); + + if (!batch) { + return; + } + + // Check to make sure the currentIndex is not greater than the runCount + if (currentIndex >= batch.runCount) { + logger.debug("[BatchTriggerV2][processBatchTaskRun] currentIndex is greater than runCount", { + batchId: batch.friendlyId, + currentIndex, + runCount: batch.runCount, + attemptCount: $attemptCount, + }); + + return; + } + + // Resolve the payload + const payloadPacket = await downloadPacketFromObjectStore( + { + data: batch.payload ?? undefined, + dataType: batch.payloadType, + }, + batch.runtimeEnvironment + ); + + const payload = await parsePacket(payloadPacket); + + if (!payload) { + logger.debug("[BatchTriggerV2][processBatchTaskRun] Failed to parse payload", { + batchId: batch.friendlyId, + currentIndex, + attemptCount: $attemptCount, + }); + + throw new Error("Failed to parse payload"); + } + + // Skip zod parsing + const $payload = payload as BatchTriggerTaskV2RequestBody["items"]; + + // Grab the next PROCESSING_BATCH_SIZE runIds + const runIds = batch.runIds.slice(currentIndex, currentIndex + PROCESSING_BATCH_SIZE); + + logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch items", { + batchId: batch.friendlyId, + currentIndex, + runIds, + attemptCount: $attemptCount, + runCount: batch.runCount, + }); + + // Combine the "window" between currentIndex and currentIndex + PROCESSING_BATCH_SIZE with the runId and the item in the payload which is an array + const itemsToProcess = runIds.map((runId, index) => ({ + runId, + item: $payload[index + currentIndex], + })); + + let workingIndex = currentIndex; + + for (const item of itemsToProcess) { + try { + await this.#processBatchTaskRunItem( + batch, + batch.runtimeEnvironment, + item, + workingIndex, + batch.options as BatchTriggerTaskServiceOptions + ); + + workingIndex++; + } catch (error) { + logger.error("[BatchTriggerV2][processBatchTaskRun] Failed to process item", { + batchId: batch.friendlyId, + currentIndex: workingIndex, + error, + }); + + // Requeue the batch to try again + await this.#enqueueBatchTaskRun(batch.id, workingIndex, $attemptCount); + return; + } + } + + // if there are more items to process, requeue the batch + if (workingIndex < batch.runCount) { + await this.#enqueueBatchTaskRun(batch.id, workingIndex, 0); + return; + } + } + + async #processBatchTaskRunItem( + batch: BatchTaskRun, + environment: AuthenticatedEnvironment, + task: { runId: string; item: BatchTriggerTaskV2RequestBody["items"][number] }, + currentIndex: number, + options?: BatchTriggerTaskServiceOptions + ) { + logger.debug("[BatchTriggerV2][processBatchTaskRunItem] Processing item", { + batchId: batch.friendlyId, + runId: task.runId, + currentIndex, + }); + + // If the item has an idempotency key, it's possible the run already exists and we should check for that + if (task.item.options?.idempotencyKey) { + const existingRun = await this._prisma.taskRun.findFirst({ + where: { + friendlyId: task.runId, + }, + }); + + if (existingRun) { + logger.debug("[BatchTriggerV2][processBatchTaskRunItem] Run already exists", { + batchId: batch.friendlyId, + runId: task.runId, + currentIndex, + }); + + return; + } + } + + const triggerTaskService = new TriggerTaskService(); + + const run = await triggerTaskService.call( + task.item.task, + environment, + { + ...task.item, + options: { + ...task.item.options, + dependentBatch: batch.dependentTaskAttemptId ? batch.friendlyId : undefined, // Only set dependentBatch if dependentAttempt is set which means batchTriggerAndWait was called + parentBatch: batch.dependentTaskAttemptId ? undefined : batch.friendlyId, // Only set parentBatch if dependentAttempt is NOT set which means batchTrigger was called + }, + }, + { + triggerVersion: options?.triggerVersion, + traceContext: options?.traceContext, + spanParentAsLink: options?.spanParentAsLink, + batchId: batch.friendlyId, + skipChecks: true, + runId: task.runId, + } + ); + + if (!run) { + throw new Error(`Failed to trigger run ${task.runId} for batch ${batch.friendlyId}`); + } + + await this._prisma.batchTaskRunItem.create({ + data: { + batchTaskRunId: batch.id, + taskRunId: run.id, + status: batchTaskRunItemStatusForRunStatus(run.status), + }, + }); + } + + async #enqueueBatchTaskRun( + batchId: string, + currentIndex: number = 0, + attemptCount: number = 0, + tx?: PrismaClientOrTransaction + ) { + await workerQueue.enqueue( + "v3.processBatchTaskRun", + { + batchId, + currentIndex, + attemptCount, + }, + { tx, jobKey: `process-batch:${batchId}` } + ); + } + + async #handlePayloadPacket( + payload: any, + pathPrefix: string, + environment: AuthenticatedEnvironment + ) { + return await startActiveSpan("handlePayloadPacket()", async (span) => { + const packet = { data: JSON.stringify(payload), dataType: "application/json" }; + + if (!packet.data) { + return packet; + } + + const { needsOffloading } = packetRequiresOffloading( + packet, + env.TASK_PAYLOAD_OFFLOAD_THRESHOLD + ); + + if (!needsOffloading) { + return packet; + } + + const filename = `${pathPrefix}/payload.json`; + + await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment); + + return { + data: filename, + dataType: "application/store", + }; + }); + } +} diff --git a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts index 0e9cc29336..5e7afc8e37 100644 --- a/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts +++ b/apps/webapp/app/v3/services/createTaskRunAttempt.server.ts @@ -72,7 +72,11 @@ export class CreateTaskRunAttemptService extends BaseService { }, batchItems: { include: { - batchTaskRun: true, + batchTaskRun: { + select: { + friendlyId: true, + }, + }, }, }, }, diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 47b3e39a2b..e09e8591a1 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -12,7 +12,7 @@ import { workerQueue } from "~/services/worker.server"; import { marqs, sanitizeQueueName } from "~/v3/marqs/index.server"; import { eventRepository } from "../eventRepository.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; -import { uploadToObjectStore } from "../r2.server"; +import { uploadPacketToObjectStore } from "../r2.server"; import { startActiveSpan } from "../tracer.server"; import { getEntitlement } from "~/services/platform.v3.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; @@ -34,6 +34,8 @@ export type TriggerTaskServiceOptions = { parentAsLinkType?: "replay" | "trigger"; batchId?: string; customIcon?: string; + runId?: string; + skipChecks?: boolean; }; export class OutOfEntitlementError extends Error { @@ -78,29 +80,31 @@ export class TriggerTaskService extends BaseService { return existingRun; } - if (environment.type !== "DEVELOPMENT") { + if (environment.type !== "DEVELOPMENT" && !options.skipChecks) { const result = await getEntitlement(environment.organizationId); if (result && result.hasAccess === false) { throw new OutOfEntitlementError(); } } - const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs); + if (!options.skipChecks) { + const queueSizeGuard = await guardQueueSizeLimitsForEnv(environment, marqs); - logger.debug("Queue size guard result", { - queueSizeGuard, - environment: { - id: environment.id, - type: environment.type, - organization: environment.organization, - project: environment.project, - }, - }); + logger.debug("Queue size guard result", { + queueSizeGuard, + environment: { + id: environment.id, + type: environment.type, + organization: environment.organization, + project: environment.project, + }, + }); - if (!queueSizeGuard.isWithinLimits) { - throw new ServiceValidationError( - `Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}` - ); + if (!queueSizeGuard.isWithinLimits) { + throw new ServiceValidationError( + `Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}` + ); + } } if ( @@ -113,7 +117,7 @@ export class TriggerTaskService extends BaseService { ); } - const runFriendlyId = generateFriendlyId("run"); + const runFriendlyId = options?.runId ?? generateFriendlyId("run"); const payloadPacket = await this.#handlePayloadPacket( body.payload, diff --git a/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql b/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql new file mode 100644 index 0000000000..6951292bde --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql @@ -0,0 +1,16 @@ +/* + Warnings: + + - A unique constraint covering the columns `[runtimeEnvironmentId,idempotencyKey]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "BatchTaskRun_runtimeEnvironmentId_taskIdentifier_idempotenc_key"; + +-- AlterTable +ALTER TABLE "BatchTaskRun" ADD COLUMN "runCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "runIds" TEXT[] DEFAULT ARRAY[]::TEXT[], +ALTER COLUMN "taskIdentifier" DROP NOT NULL; + +-- CreateIndex +CREATE UNIQUE INDEX "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey"); diff --git a/internal-packages/database/prisma/migrations/20241120174034_add_idempotency_key_expires_at_columns/migration.sql b/internal-packages/database/prisma/migrations/20241120174034_add_idempotency_key_expires_at_columns/migration.sql new file mode 100644 index 0000000000..e83887b89f --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241120174034_add_idempotency_key_expires_at_columns/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "BatchTaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "idempotencyKeyExpiresAt" TIMESTAMP(3); diff --git a/internal-packages/database/prisma/migrations/20241121114454_add_payload_columns_to_batch_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241121114454_add_payload_columns_to_batch_task_run/migration.sql new file mode 100644 index 0000000000..92f0ebc01d --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241121114454_add_payload_columns_to_batch_task_run/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "BatchTaskRun" ADD COLUMN "payload" TEXT, +ADD COLUMN "payloadType" TEXT NOT NULL DEFAULT 'application/json'; diff --git a/internal-packages/database/prisma/migrations/20241121135006_add_options_to_batch_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241121135006_add_options_to_batch_task_run/migration.sql new file mode 100644 index 0000000000..361fe593a5 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241121135006_add_options_to_batch_task_run/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BatchTaskRun" ADD COLUMN "options" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index ee6ea72166..50fa4f1ff4 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1661,8 +1661,9 @@ model TaskRun { status TaskRunStatus @default(PENDING) - idempotencyKey String? - taskIdentifier String + idempotencyKey String? + idempotencyKeyExpiresAt DateTime? + taskIdentifier String isTest Boolean @default(false) @@ -2133,32 +2134,35 @@ enum TaskQueueType { } model BatchTaskRun { - id String @id @default(cuid()) - - friendlyId String @unique - - status BatchTaskRunStatus @default(PENDING) - - idempotencyKey String? - taskIdentifier String - - checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade) - checkpointEventId String? @unique - - runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) - runtimeEnvironmentId String - - dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) + id String @id @default(cuid()) + friendlyId String @unique + idempotencyKey String? + idempotencyKeyExpiresAt DateTime? + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + runs TaskRun[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // new columns + runIds String[] @default([]) + runCount Int @default(0) + payload String? + payloadType String @default("application/json") + options Json? + + ///all the below properties are engine v1 only + items BatchTaskRunItem[] + status BatchTaskRunStatus @default(PENDING) + taskIdentifier String? + checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade) + checkpointEventId String? @unique + dependentTaskAttempt TaskRunAttempt? @relation(fields: [dependentTaskAttemptId], references: [id], onDelete: Cascade, onUpdate: Cascade) dependentTaskAttemptId String? + runDependencies TaskRunDependency[] @relation("dependentBatchRun") - items BatchTaskRunItem[] - runDependencies TaskRunDependency[] @relation("dependentBatchRun") - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - TaskRun TaskRun[] - - @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) + ///this is used for all engine versions + @@unique([runtimeEnvironmentId, idempotencyKey]) } enum BatchTaskRunStatus { diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 76eaafadd5..6c6ff14768 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -5,6 +5,7 @@ import { BatchTaskRunExecutionResult, BatchTriggerTaskRequestBody, BatchTriggerTaskResponse, + BatchTriggerTaskV2RequestBody, CanceledRunResponse, CreateEnvironmentVariableRequestBody, CreateScheduleOptions, @@ -69,6 +70,11 @@ export type TriggerOptions = { spanParentAsLink?: boolean; }; +export type BatchTriggerOptions = TriggerOptions & { + idempotencyKey?: string; + idempotencyKeyTTL?: string; +}; + export type TriggerRequestOptions = ZodFetchOptions & { publicAccessToken?: TriggerJwtOptions; }; @@ -259,6 +265,45 @@ export class ApiClient { }); } + batchTriggerV2( + body: BatchTriggerTaskV2RequestBody, + options?: BatchTriggerOptions, + requestOptions?: TriggerRequestOptions + ) { + return zodfetch( + BatchTriggerTaskResponse, + `${this.baseUrl}/api/v1/tasks/batch`, + { + method: "POST", + headers: this.#getHeaders(options?.spanParentAsLink ?? false, { + "idempotency-key": options?.idempotencyKey, + "idempotency-key-ttl": options?.idempotencyKeyTTL, + }), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ) + .withResponse() + .then(async ({ response, data }) => { + const claimsHeader = response.headers.get("x-trigger-jwt-claims"); + const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; + + const jwt = await generateJWT({ + secretKey: this.accessToken, + payload: { + ...claims, + scopes: [`read:batch:${data.batchId}`].concat(data.runs.map((r) => `read:runs:${r}`)), + }, + expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h", + }); + + return { + ...data, + publicAccessToken: jwt, + }; + }); + } + createUploadPayloadUrl(filename: string, requestOptions?: ZodFetchOptions) { return zodfetch( CreateUploadPayloadUrlResponseBody, @@ -663,11 +708,21 @@ export class ApiClient { ); } - #getHeaders(spanParentAsLink: boolean) { + #getHeaders(spanParentAsLink: boolean, additionalHeaders?: Record) { const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${this.accessToken}`, "trigger-version": VERSION, + ...Object.entries(additionalHeaders ?? {}).reduce( + (acc, [key, value]) => { + if (value !== undefined) { + acc[key] = value; + } + + return acc; + }, + {} as Record + ), }; // Only inject the context if we are inside a task diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 2e325c9138..54e279cf58 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -104,6 +104,55 @@ export const BatchTriggerTaskRequestBody = z.object({ export type BatchTriggerTaskRequestBody = z.infer; +export const BatchTriggerTaskItem = z.object({ + task: z.string(), + payload: z.any(), + context: z.any(), + options: z + .object({ + lockToVersion: z.string().optional(), + queue: QueueOptions.optional(), + concurrencyKey: z.string().optional(), + idempotencyKey: z.string().optional(), + test: z.boolean().optional(), + payloadType: z.string().optional(), + delay: z.string().or(z.coerce.date()).optional(), + ttl: z.string().or(z.number().nonnegative().int()).optional(), + tags: RunTags.optional(), + maxAttempts: z.number().int().optional(), + metadata: z.any(), + metadataType: z.string().optional(), + maxDuration: z.number().optional(), + parentAttempt: z.string().optional(), + }) + .optional(), +}); + +export type BatchTriggerTaskItem = z.infer; + +export const BatchTriggerTaskV2RequestBody = z.object({ + items: BatchTriggerTaskItem.array(), + dependentAttempt: z.string().optional(), +}); + +export type BatchTriggerTaskV2RequestBody = z.infer; + +export const BatchTriggerTaskV2Response = z.object({ + id: z.string(), + isCached: z.boolean(), + idempotencyKey: z.string().optional(), + runs: z.array( + z.object({ + id: z.string(), + taskIdentifier: z.string(), + isCached: z.boolean(), + idempotencyKey: z.string().optional(), + }) + ), +}); + +export type BatchTriggerTaskV2Response = z.infer; + export const BatchTriggerTaskResponse = z.object({ batchId: z.string(), runs: z.string().array(), diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 0039c4987d..146b93d576 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -371,15 +371,25 @@ export type RunHandle = Brand export type AnyRunHandle = RunHandle; +export type BatchedRunHandle = BrandedRun< + { + id: string; + taskIdentifier: TTaskIdentifier; + }, + TPayload, + TOutput +>; + +export type AnyBatchedRunHandle = BatchedRunHandle; + /** * A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner. */ export type BatchRunHandle = BrandedRun< { batchId: string; - runs: Array>; + runs: Array>; publicAccessToken: string; - taskIdentifier: TTaskIdentifier; }, TOutput, TPayload @@ -450,6 +460,7 @@ export interface Task */ batchTrigger: ( items: Array>, + options?: BatchTaskRunOptions, requestOptions?: TriggerApiRequestOptions ) => Promise>; @@ -491,7 +502,10 @@ export interface Task * } * ``` */ - batchTriggerAndWait: (items: Array>) => Promise>; + batchTriggerAndWait: ( + items: Array>, + options?: BatchTaskRunOptions + ) => Promise>; } export interface TaskWithSchema< @@ -662,6 +676,11 @@ export type TaskRunOptions = { maxDuration?: number; }; +export type BatchTaskRunOptions = { + idempotencyKey?: IdempotencyKey | string | string[]; + idempotencyKeyTTL?: string; +}; + export type TaskMetadataWithFunctions = TaskMetadata & { fns: { run: (payload: any, params: RunFnParams) => Promise; diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index 72cf800c82..ef8099c5d9 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -109,4 +109,4 @@ "main": "./dist/commonjs/index.js", "types": "./dist/commonjs/index.d.ts", "module": "./dist/esm/index.js" -} \ No newline at end of file +} diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 1c1b43cb2f..b19215de10 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -42,6 +42,7 @@ import type { BatchResult, BatchRunHandle, BatchRunHandleFromTypes, + BatchTaskRunOptions, InferRunTypes, inferSchemaIn, inferToolParameters, @@ -135,7 +136,7 @@ export function createTask< } ); }, - batchTrigger: async (items) => { + batchTrigger: async (items, options) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); return await batchTrigger_internal>( @@ -144,6 +145,7 @@ export function createTask< : `batchTrigger()`, params.id, items, + options, undefined, undefined, customQueue @@ -173,7 +175,7 @@ export function createTask< }); }, params.id); }, - batchTriggerAndWait: async (items) => { + batchTriggerAndWait: async (items, options) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); return await batchTriggerAndWait_internal( @@ -182,6 +184,7 @@ export function createTask< : `batchTriggerAndWait()`, params.id, items, + options, undefined, undefined, customQueue @@ -279,7 +282,7 @@ export function createSchemaTask< requestOptions ); }, - batchTrigger: async (items, requestOptions) => { + batchTrigger: async (items, options, requestOptions) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); return await batchTrigger_internal, TOutput>>( @@ -288,6 +291,7 @@ export function createSchemaTask< : `batchTrigger()`, params.id, items, + options, parsePayload, requestOptions, customQueue @@ -317,7 +321,7 @@ export function createSchemaTask< }); }, params.id); }, - batchTriggerAndWait: async (items) => { + batchTriggerAndWait: async (items, options) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); return await batchTriggerAndWait_internal, TOutput>( @@ -326,6 +330,7 @@ export function createSchemaTask< : `batchTriggerAndWait()`, params.id, items, + options, parsePayload, undefined, customQueue @@ -460,12 +465,14 @@ export function triggerAndWait( export async function batchTriggerAndWait( id: TaskIdentifier, items: Array>>, + options?: BatchTaskRunOptions, requestOptions?: ApiRequestOptions ): Promise>> { return await batchTriggerAndWait_internal, TaskOutput>( "tasks.batchTriggerAndWait()", id, items, + options, undefined, requestOptions ); @@ -500,12 +507,14 @@ export async function triggerAndPoll( export async function batchTrigger( id: TaskIdentifier, items: Array>>, + options?: BatchTaskRunOptions, requestOptions?: TriggerApiRequestOptions ): Promise>> { return await batchTrigger_internal>( "tasks.batchTrigger()", id, items, + options, undefined, requestOptions ); @@ -573,16 +582,16 @@ async function trigger_internal( async function batchTrigger_internal( name: string, - id: TRunTypes["taskIdentifier"], + taskIdentifier: TRunTypes["taskIdentifier"], items: Array>, + options?: BatchTaskRunOptions, parsePayload?: SchemaParseFn, requestOptions?: TriggerApiRequestOptions, queue?: QueueOptions ): Promise> { const apiClient = apiClientManager.clientOrThrow(); - const response = await apiClient.batchTriggerTask( - id, + const response = await apiClient.batchTriggerV2( { items: await Promise.all( items.map(async (item) => { @@ -591,6 +600,7 @@ async function batchTrigger_internal( const payloadPacket = await stringifyIO(parsedPayload); return { + task: taskIdentifier, payload: payloadPacket.data, options: { queue: item.options?.queue ?? queue, @@ -610,7 +620,11 @@ async function batchTrigger_internal( }) ), }, - { spanParentAsLink: true }, + { + spanParentAsLink: true, + idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKeyTTL: options?.idempotencyKeyTTL, + }, { name, tracer, @@ -626,7 +640,7 @@ async function batchTrigger_internal( const handle = { batchId: response.batchId, - runs: response.runs.map((id) => ({ id })), + runs: response.runs.map((id) => ({ id, taskIdentifier })), publicAccessToken: response.publicAccessToken, }; @@ -732,6 +746,7 @@ async function batchTriggerAndWait_internal( name: string, id: string, items: Array>, + options?: BatchTaskRunOptions, parsePayload?: SchemaParseFn, requestOptions?: ApiRequestOptions, queue?: QueueOptions @@ -747,8 +762,7 @@ async function batchTriggerAndWait_internal( return await tracer.startActiveSpan( name, async (span) => { - const response = await apiClient.batchTriggerTask( - id, + const response = await apiClient.batchTriggerV2( { items: await Promise.all( items.map(async (item) => { @@ -757,6 +771,7 @@ async function batchTriggerAndWait_internal( const payloadPacket = await stringifyIO(parsedPayload); return { + task: id, payload: payloadPacket.data, options: { lockToVersion: taskContext.worker?.version, @@ -777,7 +792,10 @@ async function batchTriggerAndWait_internal( ), dependentAttempt: ctx.attempt.id, }, - {}, + { + idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKeyTTL: options?.idempotencyKeyTTL, + }, requestOptions ); diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index bf005079f4..24be78e46a 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -3,26 +3,49 @@ import { logger, task, wait } from "@trigger.dev/sdk/v3"; export const batchParentTask = task({ id: "batch-parent-task", run: async () => { - const response = await batchChildTask.batchTrigger([ - { payload: "item1" }, - { payload: "item2" }, - { payload: "item3" }, - ]); - - logger.info("Batch task response", { response }); - - await wait.for({ seconds: 5 }); - await wait.until({ date: new Date(Date.now() + 1000 * 5) }); // 5 seconds - - const waitResponse = await batchChildTask.batchTriggerAndWait([ - { payload: "item4" }, - { payload: "item5" }, - { payload: "item6" }, - ]); - - logger.info("Batch task wait response", { waitResponse }); + const items = Array.from({ length: 10 }, (_, i) => ({ + payload: { + id: `item${i}`, + name: `Item Name ${i}`, + description: `This is a description for item ${i}`, + value: i, + timestamp: new Date().toISOString(), + foo: { + id: `item${i}`, + name: `Item Name ${i}`, + description: `This is a description for item ${i}`, + value: i, + timestamp: new Date().toISOString(), + }, + bar: { + id: `item${i}`, + name: `Item Name ${i}`, + description: `This is a description for item ${i}`, + value: i, + timestamp: new Date().toISOString(), + }, + }, + options: { + idempotencyKey: `item${i}`, + }, + })); + + return await batchChildTask.batchTrigger(items); + }, +}); - return response.batchId; +export const triggerWithQueue = task({ + id: "trigger-with-queue", + run: async () => { + await batchChildTask.trigger( + {}, + { + queue: { + name: "batch-queue-foo", + concurrencyLimit: 10, + }, + } + ); }, }); From e9f2f8c954aadedb5e017202e65442d3c8761eb6 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 20 Nov 2024 16:34:07 +0000 Subject: [PATCH 02/44] =?UTF-8?q?Fix=20for=20the=20DateField=20being=20one?= =?UTF-8?q?=20month=20out=E2=80=A6=20getUTCMonth()=20is=20zero=20indexed?= =?UTF-8?q?=20=F0=9F=A4=A6=E2=80=8D=E2=99=82=EF=B8=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/primitives/DateField.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index 5536f693f6..22d5292945 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -181,7 +181,7 @@ function utcDateToCalendarDate(date?: Date) { return date ? new CalendarDateTime( date.getUTCFullYear(), - date.getUTCMonth(), + date.getUTCMonth() + 1, date.getUTCDate(), date.getUTCHours(), date.getUTCMinutes(), From 5ee6b8e2eaff29e64b3632161550cae045497edd Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 21 Nov 2024 14:27:45 +0000 Subject: [PATCH 03/44] Added a custom date range filter --- .../app/components/primitives/DateField.tsx | 10 +- .../app/components/primitives/Select.tsx | 4 +- .../app/components/runs/v3/RunFilters.tsx | 231 ++++++++++++++++-- .../route.tsx | 2 + 4 files changed, 214 insertions(+), 33 deletions(-) diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index 22d5292945..9067b82246 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -1,4 +1,4 @@ -import { BellAlertIcon } from "@heroicons/react/20/solid"; +import { BellAlertIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { CalendarDateTime, createCalendar } from "@internationalized/date"; import { useDateField, useDateSegment } from "@react-aria/datepicker"; import type { DateFieldState, DateSegment } from "@react-stately/datepicker"; @@ -12,7 +12,7 @@ const variants = { small: { fieldStyles: "h-5 text-sm rounded-sm px-0.5", nowButtonVariant: "tertiary/small" as const, - clearButtonVariant: "minimal/small" as const, + clearButtonVariant: "tertiary/small" as const, }, medium: { fieldStyles: "h-7 text-base rounded px-1", @@ -134,23 +134,19 @@ export function DateField({ )} {showClearButton && ( @@ -146,6 +151,7 @@ const filterTypes = [ { name: "tags", title: "Tags", icon: }, { name: "created", title: "Created", icon: }, { name: "bulk", title: "Bulk action", icon: }, + { name: "daterange", title: "Custom date range", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -222,6 +228,7 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru + ); @@ -246,7 +253,9 @@ function Menu(props: MenuProps) { case "tasks": return props.setFilterType(undefined)} {...props} />; case "created": - return props.setFilterType(undefined)} {...props} />; + return props.setFilterType(undefined)} {...props} />; + case "daterange": + return props.setFilterType(undefined)} {...props} />; case "bulk": return props.setFilterType(undefined)} {...props} />; case "tags": @@ -256,9 +265,10 @@ function Menu(props: MenuProps) { function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { const filtered = useMemo(() => { - return filterTypes.filter((item) => - item.title.toLowerCase().includes(searchValue.toLowerCase()) - ); + return filterTypes.filter((item) => { + if (item.name === "daterange") return false; + return item.title.toLowerCase().includes(searchValue.toLowerCase()); + }); }, [searchValue]); return ( @@ -789,10 +799,6 @@ const timePeriods = [ label: "5 mins ago", value: "5m", }, - { - label: "15 mins ago", - value: "15m", - }, { label: "30 mins ago", value: "30m", @@ -801,10 +807,6 @@ const timePeriods = [ label: "1 hour ago", value: "1h", }, - { - label: "3 hours ago", - value: "3h", - }, { label: "6 hours ago", value: "6h", @@ -821,10 +823,6 @@ const timePeriods = [ label: "7 days ago", value: "7d", }, - { - label: "10 days ago", - value: "10d", - }, { label: "14 days ago", value: "14d", @@ -835,26 +833,54 @@ const timePeriods = [ }, ]; -function CreatedDropdown({ +function CreatedAtDropdown({ trigger, clearSearchValue, searchValue, onClose, + setFilterType, + hideCustomRange, }: { trigger: ReactNode; clearSearchValue: () => void; searchValue: string; onClose?: () => void; + setFilterType?: (type: FilterType | undefined) => void; + hideCustomRange?: boolean; }) { const { value, replace } = useSearchParams(); + const from = value("from"); + const to = value("to"); + const period = value("period"); + const handleChange = (newValue: string) => { clearSearchValue(); if (newValue === "all") { - if (!value) return; + if (!period && !from && !to) return; + + replace({ + period: undefined, + from: undefined, + to: undefined, + cursor: undefined, + direction: undefined, + }); + return; + } + + if (newValue === "custom") { + setFilterType?.("daterange"); + return; } - replace({ period: newValue, cursor: undefined, direction: undefined }); + replace({ + period: newValue, + from: undefined, + to: undefined, + cursor: undefined, + direction: undefined, + }); }; const filtered = useMemo(() => { @@ -864,7 +890,11 @@ function CreatedDropdown({ }, [searchValue]); return ( - + {trigger} ))} + {!hideCustomRange ? ( + + Custom date range + + ) : null} @@ -900,7 +935,7 @@ function AppliedPeriodFilter() { return ( {(search, setSearch) => ( - }> setSearch("")} + hideCustomRange + /> + )} + + ); +} + +function CustomDateRangeDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { value, replace } = useSearchParams(); + const fromSearch = dateFromString(value("from")); + const toSearch = dateFromString(value("to")); + const [from, setFrom] = useState(fromSearch); + const [to, setTo] = useState(toSearch); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + period: undefined, + cursor: undefined, + direction: undefined, + from: from?.getTime().toString(), + to: to?.getTime().toString(), + }); + + //close the dropdown + }, [from, to, replace]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +function AppliedCustomDateRangeFilter() { + const { value, del } = useSearchParams(); + + if (value("from") === undefined && value("to") === undefined) { + return null; + } + + const fromDate = dateFromString(value("from")); + const toDate = dateFromString(value("to")); + + const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to"; + + return ( + + {(search, setSearch) => ( + }> + + {rangeType === "range" ? ( + + –{" "} + + + ) : rangeType === "from" ? ( + + ) : ( + + )} + + } + onRemove={() => del(["period", "from", "to", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} /> )} ); } +function dateFromString(value: string | undefined | null): Date | undefined { + if (!value) return; + + //is it an int? + const int = parseInt(value); + if (!isNaN(int)) { + return new Date(int); + } + + return new Date(value); +} + function appliedSummary(values: string[], maxValues = 3) { if (values.length === 0) { return null; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx index c73e97868e..dffbe6031d 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx @@ -63,6 +63,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { period: url.searchParams.get("period") ?? undefined, bulkId: url.searchParams.get("bulkId") ?? undefined, tags: url.searchParams.getAll("tags").map((t) => decodeURIComponent(t)), + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, }; const { tasks, From b4ed220fd4e0199614fe40861549926a2a73596f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Thu, 21 Nov 2024 14:40:32 +0000 Subject: [PATCH 04/44] Deal with closing the custom date range --- apps/webapp/app/components/runs/v3/RunFilters.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index b7b8dc9dd6..2ca45ac346 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -939,7 +939,7 @@ function AppliedPeriodFilter() { trigger={ }> t.value === value("period"))?.label ?? value("period") } @@ -967,6 +967,7 @@ function CustomDateRangeDropdown({ searchValue: string; onClose?: () => void; }) { + const [open, setOpen] = useState(); const { value, replace } = useSearchParams(); const fromSearch = dateFromString(value("from")); const toSearch = dateFromString(value("to")); @@ -983,11 +984,11 @@ function CustomDateRangeDropdown({ to: to?.getTime().toString(), }); - //close the dropdown + setOpen(false); }, [from, to, replace]); return ( - + {trigger}
- @@ -942,7 +949,7 @@ function AppliedPeriodFilter() { trigger={ }> t.value === value("period"))?.label ?? value("period") } From 4aa5845ba464d7118e5a70b72d9e4228be609271 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 25 Nov 2024 14:44:39 +0000 Subject: [PATCH 07/44] WIP batchTriggerV2 --- apps/webapp/app/models/taskRun.server.ts | 7 +- .../routes/api.v1.tasks.$taskId.trigger.ts | 6 + apps/webapp/app/routes/api.v1.tasks.batch.ts | 16 +- apps/webapp/app/routes/api.v3.runs.$runId.ts | 5 +- .../app/utils/idempotencyKeys.server.ts | 2 + .../app/v3/services/batchTriggerV2.server.ts | 70 +++-- .../app/v3/services/triggerTask.server.ts | 31 +- packages/core/src/v3/apiClient/index.ts | 80 ++--- packages/core/src/v3/schemas/api.ts | 2 + packages/core/src/v3/schemas/common.ts | 4 + packages/core/src/v3/schemas/schemas.ts | 2 - packages/core/src/v3/types/tasks.ts | 35 ++- packages/trigger-sdk/src/v3/shared.ts | 144 +++------ packages/trigger-sdk/src/v3/tasks.ts | 4 +- references/v3-catalog/src/trigger/batch.ts | 283 +++++++++++++++++- 15 files changed, 473 insertions(+), 218 deletions(-) diff --git a/apps/webapp/app/models/taskRun.server.ts b/apps/webapp/app/models/taskRun.server.ts index 6bb7512c40..c0166515e4 100644 --- a/apps/webapp/app/models/taskRun.server.ts +++ b/apps/webapp/app/models/taskRun.server.ts @@ -6,11 +6,10 @@ import type { import { TaskRunError, TaskRunErrorCodes } from "@trigger.dev/core/v3"; import type { + BatchTaskRunItemStatus as BatchTaskRunItemStatusType, TaskRun, TaskRunAttempt, - TaskRunAttemptStatus as TaskRunAttemptStatusType, TaskRunStatus as TaskRunStatusType, - BatchTaskRunItemStatus as BatchTaskRunItemStatusType, } from "@trigger.dev/database"; import { assertNever } from "assert-never"; @@ -50,6 +49,7 @@ export function executionResultForTaskRun( return { ok: true, id: taskRun.friendlyId, + taskIdentifier: taskRun.taskIdentifier, output: attempt.output ?? undefined, outputType: attempt.outputType, } satisfies TaskRunSuccessfulExecutionResult; @@ -60,6 +60,7 @@ export function executionResultForTaskRun( return { ok: false, id: taskRun.friendlyId, + taskIdentifier: taskRun.taskIdentifier, error: { type: "INTERNAL_ERROR", code: TaskRunErrorCodes.TASK_RUN_CANCELLED, @@ -92,6 +93,7 @@ export function executionResultForTaskRun( return { ok: false, id: taskRun.friendlyId, + taskIdentifier: taskRun.taskIdentifier, error: { type: "INTERNAL_ERROR", code: TaskRunErrorCodes.CONFIGURED_INCORRECTLY, @@ -102,6 +104,7 @@ export function executionResultForTaskRun( return { ok: false, id: taskRun.friendlyId, + taskIdentifier: taskRun.taskIdentifier, error: error.data, } satisfies TaskRunFailedExecutionResult; } diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index 1eff3a9561..bae4c10cb0 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -6,6 +6,7 @@ import { env } from "~/env.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { OutOfEntitlementError, TriggerTaskService } from "~/v3/services/triggerTask.server"; @@ -41,6 +42,7 @@ const { action, loader } = createActionApiRoute( async ({ body, headers, params, authentication }) => { const { "idempotency-key": idempotencyKey, + "idempotency-key-ttl": idempotencyKeyTTL, "trigger-version": triggerVersion, "x-trigger-span-parent-as-link": spanParentAsLink, traceparent, @@ -60,6 +62,7 @@ const { action, loader } = createActionApiRoute( logger.debug("Triggering task", { taskId: params.taskId, idempotencyKey, + idempotencyKeyTTL, triggerVersion, headers, options: body.options, @@ -67,8 +70,11 @@ const { action, loader } = createActionApiRoute( traceContext, }); + const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL); + const run = await service.call(params.taskId, authentication.environment, body, { idempotencyKey: idempotencyKey ?? undefined, + idempoencyKeyExpiresAt: idempotencyKeyExpiresAt, triggerVersion: triggerVersion ?? undefined, traceContext, spanParentAsLink: spanParentAsLink === 1, diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index 64ffc8bac4..e59932bdcf 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -13,6 +13,7 @@ import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { OutOfEntitlementError } from "~/v3/services/triggerTask.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; const { action, loader } = createActionApiRoute( { @@ -55,12 +56,25 @@ const { action, loader } = createActionApiRoute( tracestate, } = headers; + logger.debug("Batch trigger request", { + idempotencyKey, + idempotencyKeyTTL, + triggerVersion, + spanParentAsLink, + isFromWorker, + triggerClient, + traceparent, + tracestate, + }); + const traceContext = traceparent && isFromWorker // If the request is from a worker, we should pass the trace context ? { traceparent, tracestate } : undefined; - const idempotencyKeyExpiresAt = resolveIdempotencyKeyTTL(idempotencyKeyTTL); + // By default, the idempotency key expires in 24 hours + const idempotencyKeyExpiresAt = + resolveIdempotencyKeyTTL(idempotencyKeyTTL) ?? new Date(Date.now() + 24 * 60 * 60 * 1000); const service = new BatchTriggerV2Service(); diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts index c0efbd94c8..66692827af 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -23,7 +23,10 @@ export const loader = createLoaderApiRoute( const result = await presenter.call(params.runId, authentication.environment); if (!result) { - return json({ error: "Run not found" }, { status: 404 }); + return json( + { error: "Run not found" }, + { status: 404, headers: { "x-should-retry": "true" } } + ); } return json(result); diff --git a/apps/webapp/app/utils/idempotencyKeys.server.ts b/apps/webapp/app/utils/idempotencyKeys.server.ts index ea88bfbfe8..58f46804df 100644 --- a/apps/webapp/app/utils/idempotencyKeys.server.ts +++ b/apps/webapp/app/utils/idempotencyKeys.server.ts @@ -36,4 +36,6 @@ export function resolveIdempotencyKeyTTL(ttl: string | undefined | null): Date | now.setDate(now.getDate() + parseInt(value, 10)); break; } + + return now; } diff --git a/apps/webapp/app/v3/services/batchTriggerV2.server.ts b/apps/webapp/app/v3/services/batchTriggerV2.server.ts index a6dfb08e4d..41848fea23 100644 --- a/apps/webapp/app/v3/services/batchTriggerV2.server.ts +++ b/apps/webapp/app/v3/services/batchTriggerV2.server.ts @@ -1,13 +1,15 @@ import { - BatchTriggerTaskResponse, BatchTriggerTaskV2RequestBody, BatchTriggerTaskV2Response, packetRequiresOffloading, parsePacket, } from "@trigger.dev/core/v3"; +import { BatchTaskRun } from "@trigger.dev/database"; import { $transaction, PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; +import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { logger } from "~/services/logger.server"; import { getEntitlement } from "~/services/platform.v3.server"; import { workerQueue } from "~/services/worker.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; @@ -18,9 +20,6 @@ import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; import { startActiveSpan } from "../tracer.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server"; -import { BatchTaskRun } from "@trigger.dev/database"; -import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server"; -import { logger } from "~/services/logger.server"; const PROCESSING_BATCH_SIZE = 50; @@ -156,7 +155,7 @@ export class BatchTriggerV2Service extends BaseService { const expiredRunIds = new Set(); let cachedRunCount = 0; - const runIds = body.items.map((item) => { + const runs = body.items.map((item) => { const cachedRun = cachedRuns.find( (r) => r.idempotencyKey === item.options?.idempotencyKey ); @@ -168,15 +167,30 @@ export class BatchTriggerV2Service extends BaseService { ) { expiredRunIds.add(cachedRun.friendlyId); - return generateFriendlyId("run"); + return { + id: generateFriendlyId("run"), + isCached: false, + idempotencyKey: item.options?.idempotencyKey ?? undefined, + taskIdentifier: item.task, + }; } cachedRunCount++; - return cachedRun.friendlyId; + return { + id: cachedRun.friendlyId, + isCached: true, + idempotencyKey: item.options?.idempotencyKey ?? undefined, + taskIdentifier: item.task, + }; } - return generateFriendlyId("run"); + return { + id: generateFriendlyId("run"), + isCached: false, + idempotencyKey: item.options?.idempotencyKey ?? undefined, + taskIdentifier: item.task, + }; }); // Calculate how many new runs we need to create @@ -187,9 +201,23 @@ export class BatchTriggerV2Service extends BaseService { batchId, }); + await this._prisma.batchTaskRun.create({ + data: { + friendlyId: batchId, + runtimeEnvironmentId: environment.id, + idempotencyKey: options.idempotencyKey, + idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, + dependentTaskAttemptId: dependentAttempt?.id, + runCount: body.items.length, + runIds: runs.map((r) => r.id), + }, + }); + return { - batchId, - runs: runIds, + id: batchId, + isCached: false, + idempotencyKey: options.idempotencyKey ?? undefined, + runs, }; } @@ -242,7 +270,7 @@ export class BatchTriggerV2Service extends BaseService { idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, dependentTaskAttemptId: dependentAttempt?.id, runCount: body.items.length, - runIds, + runIds: runs.map((r) => r.id), payload: payloadPacket.data, payloadType: payloadPacket.dataType, options, @@ -262,6 +290,7 @@ export class BatchTriggerV2Service extends BaseService { id: batch.friendlyId, isCached: false, idempotencyKey: batch.idempotencyKey ?? undefined, + runs, }; } ); @@ -427,25 +456,6 @@ export class BatchTriggerV2Service extends BaseService { currentIndex, }); - // If the item has an idempotency key, it's possible the run already exists and we should check for that - if (task.item.options?.idempotencyKey) { - const existingRun = await this._prisma.taskRun.findFirst({ - where: { - friendlyId: task.runId, - }, - }); - - if (existingRun) { - logger.debug("[BatchTriggerV2][processBatchTaskRunItem] Run already exists", { - batchId: batch.friendlyId, - runId: task.runId, - currentIndex, - }); - - return; - } - } - const triggerTaskService = new TriggerTaskService(); const run = await triggerTaskService.call( diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index e09e8591a1..468c77e4b6 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -25,9 +25,11 @@ import { parseNaturalLanguageDuration } from "@trigger.dev/core/v3/apps"; import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server"; import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server"; import { clampMaxDuration } from "../utils/maxDuration"; +import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; export type TriggerTaskServiceOptions = { idempotencyKey?: string; + idempoencyKeyExpiresAt?: Date; triggerVersion?: string; traceContext?: Record; spanParentAsLink?: boolean; @@ -54,7 +56,13 @@ export class TriggerTaskService extends BaseService { return await this.traceWithEnv("call()", environment, async (span) => { span.setAttribute("taskId", taskId); + // TODO: Add idempotency key expiring here const idempotencyKey = options.idempotencyKey ?? body.options?.idempotencyKey; + const idempotencyKeyExpiresAt = + options.idempoencyKeyExpiresAt ?? + resolveIdempotencyKeyTTL(body.options?.idempotencyKeyTTL) ?? + new Date(Date.now() + 24 * 60 * 60 * 1000); + const delayUntil = await parseDelay(body.options?.delay); const ttl = @@ -75,9 +83,25 @@ export class TriggerTaskService extends BaseService { : undefined; if (existingRun) { - span.setAttribute("runId", existingRun.friendlyId); + if ( + existingRun.idempotencyKeyExpiresAt && + existingRun.idempotencyKeyExpiresAt < new Date() + ) { + logger.debug("[TriggerTaskService][call] Idempotency key has expired", { + idempotencyKey: options.idempotencyKey, + run: existingRun, + }); + + // Update the existing batch to remove the idempotency key + await this._prisma.taskRun.update({ + where: { id: existingRun.id }, + data: { idempotencyKey: null }, + }); + } else { + span.setAttribute("runId", existingRun.friendlyId); - return existingRun; + return existingRun; + } } if (environment.type !== "DEVELOPMENT" && !options.skipChecks) { @@ -334,6 +358,7 @@ export class TriggerTaskService extends BaseService { runtimeEnvironmentId: environment.id, projectId: environment.projectId, idempotencyKey, + idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined, taskIdentifier: taskId, payload: payloadPacket.data ?? "", payloadType: payloadPacket.dataType, @@ -621,7 +646,7 @@ export class TriggerTaskService extends BaseService { const filename = `${pathPrefix}/payload.json`; - await uploadToObjectStore(filename, packet.data, packet.dataType, environment); + await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment); return { data: filename, diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 6c6ff14768..b768480a6a 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -1,11 +1,11 @@ import { z } from "zod"; import { VERSION } from "../../version.js"; +import { generateJWT } from "../jwt.js"; import { AddTagsRequestBody, BatchTaskRunExecutionResult, - BatchTriggerTaskRequestBody, - BatchTriggerTaskResponse, BatchTriggerTaskV2RequestBody, + BatchTriggerTaskV2Response, CanceledRunResponse, CreateEnvironmentVariableRequestBody, CreateScheduleOptions, @@ -29,6 +29,7 @@ import { UpdateScheduleOptions, } from "../schemas/index.js"; import { taskContext } from "../task-context-api.js"; +import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js"; import { ApiRequestOptions, CursorPagePromise, @@ -40,13 +41,13 @@ import { } from "./core.js"; import { ApiError } from "./errors.js"; import { - RunShape, AnyRunShape, - runShapeStream, + RealtimeRun, + RunShape, RunStreamCallback, RunSubscription, TaskRunShape, - RealtimeRun, + runShapeStream, } from "./runStream.js"; import { CreateEnvironmentVariableParams, @@ -56,21 +57,19 @@ import { SubscribeToRunsQueryParams, UpdateEnvironmentVariableParams, } from "./types.js"; -import { generateJWT } from "../jwt.js"; -import { AnyRunTypes, TriggerJwtOptions } from "../types/tasks.js"; export type { CreateEnvironmentVariableParams, ImportEnvironmentVariablesParams, - UpdateEnvironmentVariableParams, SubscribeToRunsQueryParams, + UpdateEnvironmentVariableParams, }; -export type TriggerOptions = { +export type ClientTriggerOptions = { spanParentAsLink?: boolean; }; -export type BatchTriggerOptions = TriggerOptions & { +export type ClientBatchTriggerOptions = ClientTriggerOptions & { idempotencyKey?: string; idempotencyKeyTTL?: string; }; @@ -94,14 +93,14 @@ const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = { }; export { isRequestOptions }; -export type { ApiRequestOptions }; export type { - RunShape, AnyRunShape, - TaskRunShape, + ApiRequestOptions, RealtimeRun, + RunShape, RunStreamCallback, RunSubscription, + TaskRunShape, }; /** @@ -179,7 +178,7 @@ export class ApiClient { triggerTask( taskId: string, body: TriggerTaskRequestBody, - options?: TriggerOptions, + clientOptions?: ClientTriggerOptions, requestOptions?: TriggerRequestOptions ) { const encodedTaskId = encodeURIComponent(taskId); @@ -189,7 +188,7 @@ export class ApiClient { `${this.baseUrl}/api/v1/tasks/${encodedTaskId}/trigger`, { method: "POST", - headers: this.#getHeaders(options?.spanParentAsLink ?? false), + headers: this.#getHeaders(clientOptions?.spanParentAsLink ?? false), body: JSON.stringify(body), }, mergeRequestOptions(this.defaultRequestOptions, requestOptions) @@ -226,58 +225,19 @@ export class ApiClient { }); } - batchTriggerTask( - taskId: string, - body: BatchTriggerTaskRequestBody, - options?: TriggerOptions, - requestOptions?: TriggerRequestOptions - ) { - const encodedTaskId = encodeURIComponent(taskId); - - return zodfetch( - BatchTriggerTaskResponse, - `${this.baseUrl}/api/v1/tasks/${encodedTaskId}/batch`, - { - method: "POST", - headers: this.#getHeaders(options?.spanParentAsLink ?? false), - body: JSON.stringify(body), - }, - mergeRequestOptions(this.defaultRequestOptions, requestOptions) - ) - .withResponse() - .then(async ({ response, data }) => { - const claimsHeader = response.headers.get("x-trigger-jwt-claims"); - const claims = claimsHeader ? JSON.parse(claimsHeader) : undefined; - - const jwt = await generateJWT({ - secretKey: this.accessToken, - payload: { - ...claims, - scopes: [`read:batch:${data.batchId}`].concat(data.runs.map((r) => `read:runs:${r}`)), - }, - expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h", - }); - - return { - ...data, - publicAccessToken: jwt, - }; - }); - } - batchTriggerV2( body: BatchTriggerTaskV2RequestBody, - options?: BatchTriggerOptions, + clientOptions?: ClientBatchTriggerOptions, requestOptions?: TriggerRequestOptions ) { return zodfetch( - BatchTriggerTaskResponse, + BatchTriggerTaskV2Response, `${this.baseUrl}/api/v1/tasks/batch`, { method: "POST", - headers: this.#getHeaders(options?.spanParentAsLink ?? false, { - "idempotency-key": options?.idempotencyKey, - "idempotency-key-ttl": options?.idempotencyKeyTTL, + headers: this.#getHeaders(clientOptions?.spanParentAsLink ?? false, { + "idempotency-key": clientOptions?.idempotencyKey, + "idempotency-key-ttl": clientOptions?.idempotencyKeyTTL, }), body: JSON.stringify(body), }, @@ -292,7 +252,7 @@ export class ApiClient { secretKey: this.accessToken, payload: { ...claims, - scopes: [`read:batch:${data.batchId}`].concat(data.runs.map((r) => `read:runs:${r}`)), + scopes: [`read:batch:${data.id}`].concat(data.runs.map((r) => `read:runs:${r.id}`)), }, expirationTime: requestOptions?.publicAccessToken?.expirationTime ?? "1h", }); diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 54e279cf58..04d069b2c8 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -76,6 +76,7 @@ export const TriggerTaskRequestBody = z.object({ queue: QueueOptions.optional(), concurrencyKey: z.string().optional(), idempotencyKey: z.string().optional(), + idempotencyKeyTTL: z.string().optional(), test: z.boolean().optional(), payloadType: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), @@ -114,6 +115,7 @@ export const BatchTriggerTaskItem = z.object({ queue: QueueOptions.optional(), concurrencyKey: z.string().optional(), idempotencyKey: z.string().optional(), + idempotencyKeyTTL: z.string().optional(), test: z.boolean().optional(), payloadType: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), diff --git a/packages/core/src/v3/schemas/common.ts b/packages/core/src/v3/schemas/common.ts index 325228d8c1..270d87e6df 100644 --- a/packages/core/src/v3/schemas/common.ts +++ b/packages/core/src/v3/schemas/common.ts @@ -251,6 +251,8 @@ export const TaskRunFailedExecutionResult = z.object({ retry: TaskRunExecutionRetry.optional(), skippedRetrying: z.boolean().optional(), usage: TaskRunExecutionUsage.optional(), + // Optional for now for backwards compatibility + taskIdentifier: z.string().optional(), }); export type TaskRunFailedExecutionResult = z.infer; @@ -261,6 +263,8 @@ export const TaskRunSuccessfulExecutionResult = z.object({ output: z.string().optional(), outputType: z.string(), usage: TaskRunExecutionUsage.optional(), + // Optional for now for backwards compatibility + taskIdentifier: z.string().optional(), }); export type TaskRunSuccessfulExecutionResult = z.infer; diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 11b50eece7..42edf8602e 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -136,8 +136,6 @@ export const QueueOptions = z.object({ * * If this property is omitted, the task can potentially use up the full concurrency of an environment. */ concurrencyLimit: z.number().int().min(0).max(1000).optional(), - /** @deprecated This feature is coming soon */ - rateLimit: RateLimitOptions.optional(), }); export type QueueOptions = z.infer; diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 146b93d576..b375d059f4 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -375,6 +375,8 @@ export type BatchedRunHandle { id: string; taskIdentifier: TTaskIdentifier; + isCached: boolean; + idempotencyKey?: string; }, TPayload, TOutput @@ -388,6 +390,8 @@ export type AnyBatchedRunHandle = BatchedRunHandle; export type BatchRunHandle = BrandedRun< { batchId: string; + isCached: boolean; + idempotencyKey?: string; runs: Array>; publicAccessToken: string; }, @@ -415,11 +419,13 @@ export type TaskRunResult = | { ok: true; id: string; + taskIdentifier: string; output: TOutput; } | { ok: false; id: string; + taskIdentifier: string; error: unknown; }; @@ -428,7 +434,12 @@ export type BatchResult = { runs: TaskRunResult[]; }; -export type BatchItem = { payload: TInput; options?: TaskRunOptions }; +export type BatchItem = { payload: TInput; options?: TriggerOptions }; + +export type BatchTriggerAndWaitItem = { + payload: TInput; + options?: TriggerAndWaitOptions; +}; export interface Task { /** @@ -447,7 +458,7 @@ export interface Task */ trigger: ( payload: TInput, - options?: TaskRunOptions, + options?: TriggerOptions, requestOptions?: TriggerApiRequestOptions ) => Promise>; @@ -460,7 +471,7 @@ export interface Task */ batchTrigger: ( items: Array>, - options?: BatchTaskRunOptions, + options?: BatchTriggerOptions, requestOptions?: TriggerApiRequestOptions ) => Promise>; @@ -480,7 +491,7 @@ export interface Task * } * ``` */ - triggerAndWait: (payload: TInput, options?: TaskRunOptions) => TaskRunPromise; + triggerAndWait: (payload: TInput, options?: TriggerAndWaitOptions) => TaskRunPromise; /** * Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs. @@ -503,8 +514,7 @@ export interface Task * ``` */ batchTriggerAndWait: ( - items: Array>, - options?: BatchTaskRunOptions + items: Array> ) => Promise>; } @@ -567,7 +577,7 @@ export type TriggerJwtOptions = { expirationTime?: number | Date | string; }; -export type TaskRunOptions = { +export type TriggerOptions = { /** * A unique key that can be used to ensure that a task is only triggered once per key. * @@ -614,6 +624,13 @@ export type TaskRunOptions = { * */ idempotencyKey?: IdempotencyKey | string | string[]; + + /** + * The time-to-live for the idempotency key. Once the TTL has passed, the key can be used again. + * + * Specify a duration string like "1h", "10s", "30m", etc. + */ + idempotencyKeyTTL?: string; maxAttempts?: number; queue?: TaskRunConcurrencyOptions; concurrencyKey?: string; @@ -676,7 +693,9 @@ export type TaskRunOptions = { maxDuration?: number; }; -export type BatchTaskRunOptions = { +export type TriggerAndWaitOptions = Omit; + +export type BatchTriggerOptions = { idempotencyKey?: IdempotencyKey | string | string[]; idempotencyKeyTTL?: string; }; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index b19215de10..5ed2239fef 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -42,7 +42,7 @@ import type { BatchResult, BatchRunHandle, BatchRunHandleFromTypes, - BatchTaskRunOptions, + BatchTriggerOptions, InferRunTypes, inferSchemaIn, inferToolParameters, @@ -60,7 +60,6 @@ import type { TaskOutput, TaskOutputHandle, TaskPayload, - TaskRunOptions, TaskRunResult, TaskSchema, TaskWithSchema, @@ -69,8 +68,10 @@ import type { ToolTask, ToolTaskParameters, TriggerApiRequestOptions, + TriggerAndWaitOptions, + BatchTriggerAndWaitItem, + TriggerOptions, } from "@trigger.dev/core/v3"; -import { z } from "zod"; export type { AnyRunHandle, @@ -90,7 +91,8 @@ export type { TaskOutput, TaskOutputHandle, TaskPayload, - TaskRunOptions, + TriggerOptions, + BatchTriggerOptions, TaskRunResult, }; @@ -175,7 +177,7 @@ export function createTask< }); }, params.id); }, - batchTriggerAndWait: async (items, options) => { + batchTriggerAndWait: async (items) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); return await batchTriggerAndWait_internal( @@ -184,7 +186,6 @@ export function createTask< : `batchTriggerAndWait()`, params.id, items, - options, undefined, undefined, customQueue @@ -321,7 +322,7 @@ export function createSchemaTask< }); }, params.id); }, - batchTriggerAndWait: async (items, options) => { + batchTriggerAndWait: async (items) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); return await batchTriggerAndWait_internal, TOutput>( @@ -330,7 +331,6 @@ export function createSchemaTask< : `batchTriggerAndWait()`, params.id, items, - options, parsePayload, undefined, customQueue @@ -383,7 +383,7 @@ export function createSchemaTask< export async function trigger( id: TaskIdentifier, payload: TaskPayload, - options?: TaskRunOptions, + options?: TriggerOptions, requestOptions?: TriggerApiRequestOptions ): Promise>> { return await trigger_internal>( @@ -417,7 +417,7 @@ export async function trigger( export function triggerAndWait( id: TaskIdentifier, payload: TaskPayload, - options?: TaskRunOptions, + options?: TriggerAndWaitOptions, requestOptions?: ApiRequestOptions ): TaskRunPromise> { return new TaskRunPromise>((resolve, reject) => { @@ -465,14 +465,12 @@ export function triggerAndWait( export async function batchTriggerAndWait( id: TaskIdentifier, items: Array>>, - options?: BatchTaskRunOptions, requestOptions?: ApiRequestOptions ): Promise>> { return await batchTriggerAndWait_internal, TaskOutput>( "tasks.batchTriggerAndWait()", id, items, - options, undefined, requestOptions ); @@ -496,7 +494,7 @@ export async function batchTriggerAndWait( export async function triggerAndPoll( id: TaskIdentifier, payload: TaskPayload, - options?: TaskRunOptions & PollOptions, + options?: TriggerOptions & PollOptions, requestOptions?: TriggerApiRequestOptions ): Promise> { const handle = await trigger(id, payload, options, requestOptions); @@ -507,7 +505,7 @@ export async function triggerAndPoll( export async function batchTrigger( id: TaskIdentifier, items: Array>>, - options?: BatchTaskRunOptions, + options?: BatchTriggerOptions, requestOptions?: TriggerApiRequestOptions ): Promise>> { return await batchTrigger_internal>( @@ -525,7 +523,7 @@ async function trigger_internal( id: TRunTypes["taskIdentifier"], payload: TRunTypes["payload"], parsePayload?: SchemaParseFn, - options?: TaskRunOptions, + options?: TriggerOptions, requestOptions?: TriggerApiRequestOptions ): Promise> { const apiClient = apiClientManager.clientOrThrow(); @@ -544,6 +542,7 @@ async function trigger_internal( test: taskContext.ctx?.run.isTest, payloadType: payloadPacket.dataType, idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKeyTTL: options?.idempotencyKeyTTL, delay: options?.delay, ttl: options?.ttl, tags: options?.tags, @@ -584,7 +583,7 @@ async function batchTrigger_internal( name: string, taskIdentifier: TRunTypes["taskIdentifier"], items: Array>, - options?: BatchTaskRunOptions, + options?: BatchTriggerOptions, parsePayload?: SchemaParseFn, requestOptions?: TriggerApiRequestOptions, queue?: QueueOptions @@ -608,6 +607,7 @@ async function batchTrigger_internal( test: taskContext.ctx?.run.isTest, payloadType: payloadPacket.dataType, idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey), + idempotencyKeyTTL: item.options?.idempotencyKeyTTL, delay: item.options?.delay, ttl: item.options?.ttl, tags: item.options?.tags, @@ -639,8 +639,10 @@ async function batchTrigger_internal( ); const handle = { - batchId: response.batchId, - runs: response.runs.map((id) => ({ id, taskIdentifier })), + batchId: response.id, + isCached: response.isCached, + idempotencyKey: response.idempotencyKey, + runs: response.runs, publicAccessToken: response.publicAccessToken, }; @@ -652,7 +654,7 @@ async function triggerAndWait_internal( id: string, payload: TPayload, parsePayload?: SchemaParseFn, - options?: TaskRunOptions, + options?: TriggerAndWaitOptions, requestOptions?: ApiRequestOptions ): Promise> { const ctx = taskContext.ctx; @@ -681,7 +683,6 @@ async function triggerAndWait_internal( concurrencyKey: options?.concurrencyKey, test: taskContext.ctx?.run.isTest, payloadType: payloadPacket.dataType, - idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), delay: options?.delay, ttl: options?.ttl, tags: options?.tags, @@ -696,29 +697,12 @@ async function triggerAndWait_internal( span.setAttribute("messaging.message.id", response.id); - if (options?.idempotencyKey) { - // If an idempotency key is provided, we can check if the result is already available - const result = await apiClient.getRunResult(response.id); - - if (result) { - logger.log( - `Result reused from previous task run with idempotency key '${options.idempotencyKey}'.`, - { - runId: response.id, - idempotencyKey: options.idempotencyKey, - } - ); - - return await handleTaskRunExecutionResult(result); - } - } - const result = await runtime.waitForTask({ id: response.id, ctx, }); - return await handleTaskRunExecutionResult(result); + return await handleTaskRunExecutionResult(result, id); }, { kind: SpanKind.PRODUCER, @@ -745,8 +729,7 @@ async function triggerAndWait_internal( async function batchTriggerAndWait_internal( name: string, id: string, - items: Array>, - options?: BatchTaskRunOptions, + items: Array>, parsePayload?: SchemaParseFn, requestOptions?: ApiRequestOptions, queue?: QueueOptions @@ -779,7 +762,6 @@ async function batchTriggerAndWait_internal( concurrencyKey: item.options?.concurrencyKey, test: taskContext.ctx?.run.isTest, payloadType: payloadPacket.dataType, - idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey), delay: item.options?.delay, ttl: item.options?.ttl, tags: item.options?.tags, @@ -792,77 +774,19 @@ async function batchTriggerAndWait_internal( ), dependentAttempt: ctx.attempt.id, }, - { - idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), - idempotencyKeyTTL: options?.idempotencyKeyTTL, - }, + {}, requestOptions ); - span.setAttribute("messaging.message.id", response.batchId); - - const getBatchResults = async (): Promise => { - // We need to check if the results are already available, but only if any of the items options has an idempotency key - const hasIdempotencyKey = items.some((item) => item.options?.idempotencyKey); - - if (hasIdempotencyKey) { - const results = await apiClient.getBatchResults(response.batchId); - - if (results) { - return results; - } - } - - return { - id: response.batchId, - items: [], - }; - }; - - const existingResults = await getBatchResults(); - - const incompleteRuns = response.runs.filter( - (runId) => !existingResults.items.some((item) => item.id === runId) - ); - - if (incompleteRuns.length === 0) { - logger.log( - `Results reused from previous task runs because of the provided idempotency keys.` - ); - - // All runs are already completed - const runs = await handleBatchTaskRunExecutionResult(existingResults.items); - - return { - id: existingResults.id, - runs, - }; - } + span.setAttribute("messaging.message.id", response.id); const result = await runtime.waitForBatch({ - id: response.batchId, - runs: incompleteRuns, + id: response.id, + runs: response.runs.map((run) => run.id), ctx, }); - // Combine the already completed runs with the newly completed runs, ordered by the original order - const combinedItems: BatchTaskRunExecutionResult["items"] = []; - - for (const runId of response.runs) { - const existingItem = existingResults.items.find((item) => item.id === runId); - - if (existingItem) { - combinedItems.push(existingItem); - } else { - const newItem = result.items.find((item) => item.id === runId); - - if (newItem) { - combinedItems.push(newItem); - } - } - } - - const runs = await handleBatchTaskRunExecutionResult(combinedItems); + const runs = await handleBatchTaskRunExecutionResult(result.items, id); return { id: result.id, @@ -893,7 +817,8 @@ async function batchTriggerAndWait_internal( } async function handleBatchTaskRunExecutionResult( - items: Array + items: Array, + taskIdentifier: string ): Promise>> { const someObjectStoreOutputs = items.some( (item) => item.ok && item.outputType === "application/store" @@ -902,7 +827,7 @@ async function handleBatchTaskRunExecutionResult( if (!someObjectStoreOutputs) { const results = await Promise.all( items.map(async (item) => { - return await handleTaskRunExecutionResult(item); + return await handleTaskRunExecutionResult(item, taskIdentifier); }) ); @@ -914,7 +839,7 @@ async function handleBatchTaskRunExecutionResult( async (span) => { const results = await Promise.all( items.map(async (item) => { - return await handleTaskRunExecutionResult(item); + return await handleTaskRunExecutionResult(item, taskIdentifier); }) ); @@ -928,7 +853,8 @@ async function handleBatchTaskRunExecutionResult( } async function handleTaskRunExecutionResult( - execution: TaskRunExecutionResult + execution: TaskRunExecutionResult, + taskIdentifier: string ): Promise> { if (execution.ok) { const outputPacket = { data: execution.output, dataType: execution.outputType }; @@ -937,12 +863,14 @@ async function handleTaskRunExecutionResult( return { ok: true, id: execution.id, + taskIdentifier: execution.taskIdentifier ?? taskIdentifier, output: await parsePacket(importedPacket), }; } else { return { ok: false, id: execution.id, + taskIdentifier: execution.taskIdentifier ?? taskIdentifier, error: createErrorTaskError(execution.error), }; } diff --git a/packages/trigger-sdk/src/v3/tasks.ts b/packages/trigger-sdk/src/v3/tasks.ts index 470a73895f..55e1bc0217 100644 --- a/packages/trigger-sdk/src/v3/tasks.ts +++ b/packages/trigger-sdk/src/v3/tasks.ts @@ -24,7 +24,7 @@ import type { TaskOptions, TaskOutput, TaskPayload, - TaskRunOptions, + TriggerOptions, TaskRunResult, } from "./shared.js"; @@ -40,7 +40,7 @@ export type { TaskOptions, TaskOutput, TaskPayload, - TaskRunOptions, + TriggerOptions, TaskRunResult, }; diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index 24be78e46a..d6cf50ea4f 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -1,4 +1,6 @@ -import { logger, task, wait } from "@trigger.dev/sdk/v3"; +import { auth, logger, runs, task, tasks, wait } from "@trigger.dev/sdk/v3"; +import assert from "node:assert"; +import { randomUUID } from "node:crypto"; export const batchParentTask = task({ id: "batch-parent-task", @@ -104,3 +106,282 @@ export const taskThatFails = task({ }; }, }); + +export const batchV2TestTask = task({ + id: "batch-v2-test", + run: async () => { + // TODO tests: + // tasks.batchTrigger + // tasks.batchTriggerAndWait + // myTask.batchTriggerAndWait + const response1 = await batchV2TestChild.batchTrigger([ + { payload: { foo: "bar" } }, + { payload: { foo: "baz" } }, + ]); + + logger.info("Response 1", { response1 }); + + // Check that the batch ID matches this kind of ID: batch_g5obektq4xv699mq7eb9q + assert.match(response1.batchId, /^batch_[a-z0-9]{21}$/, "response1: Batch ID is invalid"); + assert.equal(response1.runs.length, 2, "response1: Items length is invalid"); + assert.match(response1.runs[0].id, /^run_[a-z0-9]{21}$/, "response1: Run ID is invalid"); + assert.equal( + response1.runs[0].taskIdentifier, + "batch-v2-test-child", + "response1: runs[0] Task identifier is invalid" + ); + assert.equal(response1.runs[0].isCached, false, "response1: runs[0] Run is cached"); + assert.equal( + response1.runs[0].idempotencyKey, + undefined, + "response1: runs[0] Idempotent key is invalid" + ); + + assert.match( + response1.runs[1].id, + /^run_[a-z0-9]{21}$/, + "response1: runs[1] Run ID is invalid" + ); + assert.equal( + response1.runs[1].taskIdentifier, + "batch-v2-test-child", + "response1: runs[1] Task identifier is invalid" + ); + assert.equal(response1.runs[1].isCached, false, "response1: runs[1] Run is cached"); + assert.equal( + response1.runs[1].idempotencyKey, + undefined, + "response1: runs[1] Idempotent key is invalid" + ); + + await auth.withAuth({ accessToken: response1.publicAccessToken }, async () => { + const [run0, run1] = await Promise.all([ + runs.retrieve(response1.runs[0].id), + runs.retrieve(response1.runs[1].id), + ]); + + logger.debug("retrieved response 1 runs", { run0, run1 }); + + for await (const liveRun0 of runs.subscribeToRun(response1.runs[0].id)) { + logger.debug("subscribed to run0", { liveRun0 }); + } + + for await (const liveRun1 of runs.subscribeToRun(response1.runs[1].id)) { + logger.debug("subscribed to run1", { liveRun1 }); + } + }); + + // Now let's do another batch trigger, this time with 100 items, and immediately try and retrieve the last run + const response2 = await batchV2TestChild.batchTrigger( + Array.from({ length: 30 }, (_, i) => ({ + payload: { foo: `bar${i}` }, + })) + ); + + logger.info("Response 2", { response2 }); + + assert.equal(response2.runs.length, 30, "response2: Items length is invalid"); + + const lastRunId = response2.runs[response2.runs.length - 1].id; + + const lastRun = await runs.retrieve(lastRunId); + + logger.info("Last run", { lastRun }); + + assert.equal(lastRun.id, lastRunId, "response2: Last run ID is invalid"); + + // okay, now we are going to test using the batch-level idempotency key + // we need to test that when reusing the idempotency key, we retrieve the same batch and runs and the response is correct + // we will also need to test idempotencyKeyTTL and make sure that the key is not reused after the TTL has expired + const idempotencyKey1 = randomUUID(); + + const response3 = await batchV2TestChild.batchTrigger( + [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }], + { + idempotencyKey: idempotencyKey1, + idempotencyKeyTTL: "5s", + } + ); + + logger.info("Response 3", { response3 }); + + assert.equal(response3.isCached, false, "response3: Batch is cached"); + assert.ok(response3.idempotencyKey, "response3: Batch idempotency key is invalid"); + assert.equal(response3.runs.length, 2, "response3: Items length is invalid"); + assert.equal(response3.runs[0].isCached, false, "response3: runs[0] Run is cached"); + assert.equal(response3.runs[1].isCached, false, "response3: runs[1] Run is cached"); + + const response4 = await batchV2TestChild.batchTrigger( + [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }], + { + idempotencyKey: idempotencyKey1, + idempotencyKeyTTL: "5s", + } + ); + + logger.info("Response 4", { response4 }); + + assert.equal(response4.batchId, response3.batchId, "response4: Batch ID is invalid"); + assert.equal(response4.isCached, true, "response4: Batch is not cached"); + assert.equal(response4.runs.length, 2, "response4: Items length is invalid"); + assert.equal(response4.runs[0].isCached, true, "response4: runs[0] Run is not cached"); + assert.equal(response4.runs[1].isCached, true, "response4: runs[1] Run is not cached"); + assert.equal( + response4.runs[0].id, + response3.runs[0].id, + "response4: runs[0] Run ID is invalid" + ); + assert.equal( + response4.runs[1].id, + response3.runs[1].id, + "response4: runs[1] Run ID is invalid" + ); + + await wait.for({ seconds: 6 }); + + const response5 = await batchV2TestChild.batchTrigger( + [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }], + { + idempotencyKey: idempotencyKey1, + idempotencyKeyTTL: "5s", + } + ); + + logger.info("Response 5", { response5 }); + + assert.equal(response5.isCached, false, "response5: Batch is cached"); + assert.notEqual(response5.batchId, response3.batchId, "response5: Batch ID is invalid"); + assert.equal(response5.runs.length, 2, "response5: Items length is invalid"); + assert.equal(response5.runs[0].isCached, false, "response5: runs[0] Run is cached"); + assert.equal(response5.runs[1].isCached, false, "response5: runs[1] Run is cached"); + + // Now we need to test with idempotency keys on the individual runs + // The first test will make sure that the idempotency key is passed to the child task + const idempotencyKeyChild1 = randomUUID(); + const idempotencyKeyChild2 = randomUUID(); + + const response6 = await batchV2TestChild.batchTrigger([ + { + payload: { foo: "bar" }, + options: { idempotencyKey: idempotencyKeyChild1, idempotencyKeyTTL: "5s" }, + }, + { + payload: { foo: "baz" }, + options: { idempotencyKey: idempotencyKeyChild2, idempotencyKeyTTL: "15s" }, + }, + ]); + + logger.info("Response 6", { response6 }); + + assert.equal(response6.runs.length, 2, "response6: Items length is invalid"); + assert.equal(response6.runs[0].isCached, false, "response6: runs[0] Run is cached"); + assert.equal(response6.runs[1].isCached, false, "response6: runs[1] Run is cached"); + assert.ok(response6.runs[0].idempotencyKey, "response6: runs[0] Idempotent key is invalid"); + assert.ok(response6.runs[1].idempotencyKey, "response6: runs[1] Idempotent key is invalid"); + + const response7 = await batchV2TestChild.batchTrigger([ + { payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } }, + { payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } }, + ]); + + logger.info("Response 7", { response7 }); + + assert.equal(response7.runs.length, 2, "response7: Items length is invalid"); + assert.equal(response7.runs[0].isCached, true, "response7: runs[0] Run is not cached"); + assert.equal(response7.runs[1].isCached, true, "response7: runs[1] Run is not cached"); + assert.equal( + response7.runs[0].id, + response6.runs[0].id, + "response7: runs[0] Run ID is invalid" + ); + assert.equal( + response7.runs[1].id, + response6.runs[1].id, + "response7: runs[1] Run ID is invalid" + ); + + await wait.for({ seconds: 6 }); + + // Now we need to test that the first run is not cached and is a new run, and the second run is cached + const response8 = await batchV2TestChild.batchTrigger([ + { payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } }, + { payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } }, + ]); + + logger.info("Response 8", { response8 }); + + assert.equal(response8.runs.length, 2, "response8: Items length is invalid"); + assert.equal(response8.runs[0].isCached, false, "response8: runs[0] Run is cached"); + assert.equal(response8.runs[1].isCached, true, "response8: runs[1] Run is not cached"); + assert.notEqual( + response8.runs[0].id, + response6.runs[0].id, + "response8: runs[0] Run ID is invalid" + ); + assert.equal( + response8.runs[1].id, + response6.runs[1].id, + "response8: runs[1] Run ID is invalid" + ); + + // Now we need to test with batchTriggerAndWait + const response9 = await batchV2TestChild.batchTriggerAndWait([ + { payload: { foo: "bar" } }, + { payload: { foo: "baz" } }, + ]); + + logger.debug("Response 9", { response9 }); + + assert.match(response9.id, /^batch_[a-z0-9]{21}$/, "response9: Batch ID is invalid"); + assert.equal(response9.runs.length, 2, "response9: Items length is invalid"); + assert.ok(response9.runs[0].ok, "response9: runs[0] is not ok"); + assert.ok(response9.runs[1].ok, "response9: runs[1] is not ok"); + assert.equal( + response9.runs[0].taskIdentifier, + "batch-v2-test-child", + "response9: runs[0] Task identifier is invalid" + ); + assert.equal( + response9.runs[1].taskIdentifier, + "batch-v2-test-child", + "response9: runs[1] Task identifier is invalid" + ); + assert.deepEqual( + response9.runs[0].output, + { foo: "bar" }, + "response9: runs[0] result is invalid" + ); + assert.deepEqual( + response9.runs[1].output, + { foo: "baz" }, + "response9: runs[1] result is invalid" + ); + + // Now batchTriggerAndWait with 100 items + const response10 = await batchV2TestChild.batchTriggerAndWait( + Array.from({ length: 100 }, (_, i) => ({ + payload: { foo: `bar${i}` }, + })) + ); + + logger.debug("Response 10", { response10 }); + + assert.match(response10.id, /^batch_[a-z0-9]{21}$/, "response10: Batch ID is invalid"); + assert.equal(response10.runs.length, 100, "response10: Items length is invalid"); + + // Now repeat the first few tests using `tasks.batchTrigger`: + const response11 = await tasks.batchTrigger("batch-v2-test-child", [ + { payload: { foo: "bar" } }, + { payload: { foo: "baz" } }, + ]); + + logger.debug("Response 11", { response11 }); + }, +}); + +export const batchV2TestChild = task({ + id: "batch-v2-test-child", + run: async (payload: any) => { + return payload; + }, +}); From d69abd184817c7178d8a723fc3413b1e55fcccdb Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Mon, 25 Nov 2024 15:57:27 +0000 Subject: [PATCH 08/44] Finished removing rate limit from the webapp --- apps/webapp/app/v3/services/createBackgroundWorker.server.ts | 2 -- apps/webapp/app/v3/services/triggerTask.server.ts | 2 -- 2 files changed, 4 deletions(-) diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index a314b0f401..cc54952489 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -190,7 +190,6 @@ export async function createBackgroundTasks( }, update: { concurrencyLimit, - rateLimit: task.queue?.rateLimit, }, create: { friendlyId: generateFriendlyId("queue"), @@ -198,7 +197,6 @@ export async function createBackgroundTasks( concurrencyLimit, runtimeEnvironmentId: worker.runtimeEnvironmentId, projectId: worker.projectId, - rateLimit: task.queue?.rateLimit, type: task.queue?.name ? "NAMED" : "VIRTUAL", }, }); diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 468c77e4b6..2b583aa5bc 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -457,7 +457,6 @@ export class TriggerTaskService extends BaseService { data: { concurrencyLimit: typeof concurrencyLimit === "number" ? concurrencyLimit : null, - rateLimit: body.options.queue.rateLimit, }, }); @@ -481,7 +480,6 @@ export class TriggerTaskService extends BaseService { concurrencyLimit, runtimeEnvironmentId: environment.id, projectId: environment.projectId, - rateLimit: body.options.queue.rateLimit, type: "NAMED", }, }); From a664859834599c0bff4cee01e488e154ad225d7f Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 26 Nov 2024 09:46:45 +0000 Subject: [PATCH 09/44] Added an index TaskRun to make useRealtimeBatch performant --- .changeset/ten-pans-itch.md | 25 +++++++++++++++++++ .../app/services/realtimeClient.server.ts | 9 ++++++- .../migration.sql | 2 ++ .../database/prisma/schema.prisma | 2 ++ .../react-hooks/src/hooks/useTaskTrigger.ts | 4 +-- packages/trigger-sdk/src/v3/shared.ts | 10 +++----- references/v3-catalog/src/trigger/batch.ts | 15 +++++++++++ 7 files changed, 58 insertions(+), 9 deletions(-) create mode 100644 .changeset/ten-pans-itch.md create mode 100644 internal-packages/database/prisma/migrations/20241126094217_add_batch_id_index_to_task_run/migration.sql diff --git a/.changeset/ten-pans-itch.md b/.changeset/ten-pans-itch.md new file mode 100644 index 0000000000..4538c457a9 --- /dev/null +++ b/.changeset/ten-pans-itch.md @@ -0,0 +1,25 @@ +--- +"@trigger.dev/react-hooks": minor +"@trigger.dev/sdk": minor +"@trigger.dev/core": minor +--- + +Improved Batch Triggering: + +- The new Batch Trigger endpoint is now asynchronous and supports up to 500 runs per request. +- The new endpoint also supports triggering multiple different tasks in a single batch request (support in the SDK coming soon). +- The existing `batchTrigger` method now supports the new endpoint, and shouldn't require any changes to your code. + +- Idempotency keys now expire after 24 hours, and you can customize the expiration time when creating a new key by using the `idempotencyKeyTTL` parameter: + +```ts +await myTask.batchTrigger([{ payload: { foo: "bar" }}], { idempotencyKey: "my-key", idempotencyKeyTTL: "60s" }) +// Works for individual items as well: +await myTask.batchTrigger([{ payload: { foo: "bar" }, options: { idempotencyKey: "my-key", idempotencyKeyTTL: "60s" }}]) +// And `trigger`: +await myTask.trigger({ foo: "bar" }, { idempotencyKey: "my-key", idempotencyKeyTTL: "60s" }); +``` + +### Breaking Changes + +- We've removed the `idempotencyKey` option from `triggerAndWait` and `batchTriggerAndWait`, because it can lead to permanently frozen runs in deployed tasks. We're working on upgrading our entire system to support idempotency keys on these methods, and we'll re-add the option once that's complete. diff --git a/apps/webapp/app/services/realtimeClient.server.ts b/apps/webapp/app/services/realtimeClient.server.ts index c1e63292e0..a4939d2809 100644 --- a/apps/webapp/app/services/realtimeClient.server.ts +++ b/apps/webapp/app/services/realtimeClient.server.ts @@ -52,7 +52,14 @@ export class RealtimeClient { batchId: string, clientVersion?: string ) { - return this.#streamRunsWhere(url, environment, `"batchId"='${batchId}'`, clientVersion); + const whereClauses: string[] = [ + `"runtimeEnvironmentId"='${environment.id}'`, + `"batchId"='${batchId}'`, + ]; + + const whereClause = whereClauses.join(" AND "); + + return this.#streamRunsWhere(url, environment, whereClause, clientVersion); } async streamRuns( diff --git a/internal-packages/database/prisma/migrations/20241126094217_add_batch_id_index_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241126094217_add_batch_id_index_to_task_run/migration.sql new file mode 100644 index 0000000000..08370b7a4c --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241126094217_add_batch_id_index_to_task_run/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_runtimeEnvironmentId_batchId_idx" ON "TaskRun"("runtimeEnvironmentId", "batchId"); \ No newline at end of file diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 50fa4f1ff4..e8e094ec3c 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1804,6 +1804,8 @@ model TaskRun { @@index([completedAt]) // Schedule list page @@index([scheduleId, createdAt(sort: Desc)]) + // Finding runs in a batch + @@index([runtimeEnvironmentId, batchId]) } enum TaskRunStatus { diff --git a/packages/react-hooks/src/hooks/useTaskTrigger.ts b/packages/react-hooks/src/hooks/useTaskTrigger.ts index 98524dfd72..e2c41ad172 100644 --- a/packages/react-hooks/src/hooks/useTaskTrigger.ts +++ b/packages/react-hooks/src/hooks/useTaskTrigger.ts @@ -8,7 +8,7 @@ import { makeIdempotencyKey, RunHandleFromTypes, stringifyIO, - TaskRunOptions, + TriggerOptions, } from "@trigger.dev/core/v3"; import useSWRMutation from "swr/mutation"; import { useApiClient, UseApiClientOptions } from "./useApiClient.js"; @@ -64,7 +64,7 @@ export function useTaskTrigger( id: string, { arg: { payload, options }, - }: { arg: { payload: TaskPayload; options?: TaskRunOptions } } + }: { arg: { payload: TaskPayload; options?: TriggerOptions } } ) { const payloadPacket = await stringifyIO(payload); diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 5ed2239fef..d9f83e4d68 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -9,14 +9,12 @@ import { accessoryAttributes, apiClientManager, ApiRequestOptions, - BatchTaskRunExecutionResult, conditionallyImportPacket, convertToolParametersToSchema, createErrorTaskError, defaultRetryOptions, getSchemaParseFn, InitOutput, - logger, makeIdempotencyKey, parsePacket, Queue, @@ -42,6 +40,7 @@ import type { BatchResult, BatchRunHandle, BatchRunHandleFromTypes, + BatchTriggerAndWaitItem, BatchTriggerOptions, InferRunTypes, inferSchemaIn, @@ -67,9 +66,8 @@ import type { TaskWithToolOptions, ToolTask, ToolTaskParameters, - TriggerApiRequestOptions, TriggerAndWaitOptions, - BatchTriggerAndWaitItem, + TriggerApiRequestOptions, TriggerOptions, } from "@trigger.dev/core/v3"; @@ -79,6 +77,7 @@ export type { BatchItem, BatchResult, BatchRunHandle, + BatchTriggerOptions, Queue, RunHandle, RunHandleOutput, @@ -91,9 +90,8 @@ export type { TaskOutput, TaskOutputHandle, TaskPayload, - TriggerOptions, - BatchTriggerOptions, TaskRunResult, + TriggerOptions, }; export { SubtaskUnwrapError, TaskRunPromise }; diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index d6cf50ea4f..1bbffab76e 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -376,6 +376,21 @@ export const batchV2TestTask = task({ ]); logger.debug("Response 11", { response11 }); + + assert.match(response11.batchId, /^batch_[a-z0-9]{21}$/, "response11: Batch ID is invalid"); + assert.equal(response11.runs.length, 2, "response11: Items length is invalid"); + assert.match(response11.runs[0].id, /^run_[a-z0-9]{21}$/, "response11: Run ID is invalid"); + assert.equal( + response11.runs[0].taskIdentifier, + "batch-v2-test-child", + "response11: runs[0] Task identifier is invalid" + ); + assert.equal(response11.runs[0].isCached, false, "response11: runs[0] Run is cached"); + assert.equal( + response11.runs[0].idempotencyKey, + undefined, + "response11: runs[0] Idempotent key is invalid" + ); }, }); From a0dfd2d4f78de1266f5d0a7d9c1ba8e17cbb2520 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 25 Nov 2024 15:57:15 +0000 Subject: [PATCH 10/44] =?UTF-8?q?Renamed=20the=20period=20filter=20labels?= =?UTF-8?q?=20to=20be=20=E2=80=9CLast=20X=20mins=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/components/runs/v3/RunFilters.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index d5ef8ff62d..cd5c2ac536 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -802,45 +802,45 @@ function AppliedTagsFilter() { const timePeriods = [ { - label: "All periods", - value: "all", - }, - { - label: "5 mins ago", + label: "Last 5 mins", value: "5m", }, { - label: "30 mins ago", + label: "Last 30 mins", value: "30m", }, { - label: "1 hour ago", + label: "Last 1 hour", value: "1h", }, { - label: "6 hours ago", + label: "Last 6 hours", value: "6h", }, { - label: "1 day ago", + label: "Last 1 day", value: "1d", }, { - label: "3 days ago", + label: "Last 3 days", value: "3d", }, { - label: "7 days ago", + label: "Last 7 days", value: "7d", }, { - label: "14 days ago", + label: "Last 14 days", value: "14d", }, { - label: "30 days ago", + label: "Last 30 days", value: "30d", }, + { + label: "All periods", + value: "all", + }, ]; function CreatedAtDropdown({ From daec8f44c2377acdee6da27a75038e4731ced1f1 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 26 Nov 2024 11:16:04 +0000 Subject: [PATCH 11/44] Denormalize background worker columns into TaskRun --- apps/webapp/app/v3/marqs/devQueueConsumer.server.ts | 3 +++ apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts | 3 +++ apps/webapp/app/v3/services/triggerTask.server.ts | 3 +++ .../migration.sql | 2 ++ .../migration.sql | 3 +++ internal-packages/database/prisma/schema.prisma | 5 +++++ 6 files changed, 19 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20241126110327_add_denormalized_task_version_to_task_run/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20241126110902_add_more_worker_columns_to_task_run/migration.sql diff --git a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts index 38a515954a..d629225a9c 100644 --- a/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/devQueueConsumer.server.ts @@ -378,6 +378,9 @@ export class DevQueueConsumer { lockedById: backgroundTask.id, status: "EXECUTING", lockedToVersionId: backgroundWorker.id, + taskVersion: backgroundWorker.version, + sdkVersion: backgroundWorker.sdkVersion, + cliVersion: backgroundWorker.cliVersion, startedAt: existingTaskRun.startedAt ?? new Date(), maxDurationInSeconds: getMaxDuration( existingTaskRun.maxDurationInSeconds, diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 3c60fa88ca..041f4fa57c 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -407,6 +407,9 @@ export class SharedQueueConsumer { lockedAt: new Date(), lockedById: backgroundTask.id, lockedToVersionId: deployment.worker.id, + taskVersion: deployment.worker.version, + sdkVersion: deployment.worker.sdkVersion, + cliVersion: deployment.worker.cliVersion, startedAt: existingTaskRun.startedAt ?? new Date(), baseCostInCents: env.CENTS_PER_RUN, machinePreset: machinePresetFromConfig(backgroundTask.machineConfig ?? {}).name, diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 2b583aa5bc..26a1713e5d 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -369,6 +369,9 @@ export class TriggerTaskService extends BaseService { parentSpanId: options.parentAsLinkType === "replay" ? undefined : traceparent?.spanId, lockedToVersionId: lockedToBackgroundWorker?.id, + taskVersion: lockedToBackgroundWorker?.version, + sdkVersion: lockedToBackgroundWorker?.sdkVersion, + cliVersion: lockedToBackgroundWorker?.cliVersion, concurrencyKey: body.options?.concurrencyKey, queue: queueName, isTest: body.options?.test ?? false, diff --git a/internal-packages/database/prisma/migrations/20241126110327_add_denormalized_task_version_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241126110327_add_denormalized_task_version_to_task_run/migration.sql new file mode 100644 index 0000000000..291bf2a7ed --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241126110327_add_denormalized_task_version_to_task_run/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "taskVersion" TEXT; diff --git a/internal-packages/database/prisma/migrations/20241126110902_add_more_worker_columns_to_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241126110902_add_more_worker_columns_to_task_run/migration.sql new file mode 100644 index 0000000000..b11b000b8b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241126110902_add_more_worker_columns_to_task_run/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "TaskRun" ADD COLUMN "cliVersion" TEXT, +ADD COLUMN "sdkVersion" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index e8e094ec3c..3081647f45 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1692,6 +1692,11 @@ model TaskRun { /// Denormized column that holds the raw tags runTags String[] + /// Denormalized version of the background worker task + taskVersion String? + sdkVersion String? + cliVersion String? + checkpoints Checkpoint[] startedAt DateTime? From aa82f8d100f82ec0a95f1841335abc7f4db0654e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Mon, 25 Nov 2024 17:38:40 +0000 Subject: [PATCH 12/44] Use the runTags column on TaskRun --- .../presenters/v3/RunListPresenter.server.ts | 23 ++++--------------- 1 file changed, 4 insertions(+), 19 deletions(-) diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index 01f7f783d7..4223f310f2 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -169,7 +169,7 @@ export class RunListPresenter extends BasePresenter { costInCents: number; baseCostInCents: number; usageDurationMs: BigInt; - tags: string[]; + tags: null | string[]; depth: number; rootTaskRunId: string | null; }[] @@ -197,15 +197,11 @@ export class RunListPresenter extends BasePresenter { tr."usageDurationMs" AS "usageDurationMs", tr."depth" AS "depth", tr."rootTaskRunId" AS "rootTaskRunId", - array_remove(array_agg(tag.name), NULL) AS "tags" + tr."runTags" AS "tags" FROM ${sqlDatabaseSchema}."TaskRun" tr LEFT JOIN ${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id -LEFT JOIN - ${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg ON tr.id = trtg."A" -LEFT JOIN - ${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id WHERE -- project tr."projectId" = ${project.id} @@ -257,18 +253,7 @@ WHERE } ${ tags && tags.length > 0 - ? Prisma.sql`AND ( - tr.id IN ( - SELECT - trtg."A" - FROM - ${sqlDatabaseSchema}."_TaskRunToTaskRunTag" trtg - JOIN - ${sqlDatabaseSchema}."TaskRunTag" tag ON trtg."B" = tag.id - WHERE - tag.name IN (${Prisma.join(tags)}) - ) - )` + ? Prisma.sql`AND tr."runTags" && ARRAY[${Prisma.join(tags)}]::text[]` : Prisma.empty } ${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty} @@ -340,7 +325,7 @@ WHERE costInCents: run.costInCents, baseCostInCents: run.baseCostInCents, usageDurationMs: Number(run.usageDurationMs), - tags: run.tags.sort((a, b) => a.localeCompare(b)), + tags: run.tags ? run.tags.sort((a, b) => a.localeCompare(b)) : [], depth: run.depth, rootTaskRunId: run.rootTaskRunId, }; From 976b2754df185b32c71a44416826c6ede1f752cc Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 26 Nov 2024 10:32:12 +0000 Subject: [PATCH 13/44] Add TaskRun ("projectId", "id" DESC) index --- .../migration.sql | 2 ++ internal-packages/database/prisma/schema.prisma | 1 + 2 files changed, 3 insertions(+) create mode 100644 internal-packages/database/prisma/migrations/20241125174424_task_run_project_id_id_desc_index_for_runs_list/migration.sql diff --git a/internal-packages/database/prisma/migrations/20241125174424_task_run_project_id_id_desc_index_for_runs_list/migration.sql b/internal-packages/database/prisma/migrations/20241125174424_task_run_project_id_id_desc_index_for_runs_list/migration.sql new file mode 100644 index 0000000000..93f7cdc6b3 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241125174424_task_run_project_id_id_desc_index_for_runs_list/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX CONCURRENTLY IF NOT EXISTS "TaskRun_projectId_id_idx" ON "TaskRun"("projectId", "id" DESC); diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 3081647f45..7bec6e040d 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -1796,6 +1796,7 @@ model TaskRun { @@index([projectId, createdAt, taskIdentifier]) //Runs list @@index([projectId]) + @@index([projectId, id(sort: Desc)]) @@index([projectId, taskIdentifier]) @@index([projectId, status]) @@index([projectId, taskIdentifier, status]) From 1674d337f870dd61e7fcccd841f52196c94ad3e2 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 26 Nov 2024 15:22:38 +0000 Subject: [PATCH 14/44] Improved the v2 batch trigger endpoint to process items in parallel and also added a threshold, below which the processing of items is async --- .changeset/wild-needles-hunt.md | 7 + apps/webapp/app/routes/api.v1.tasks.batch.ts | 12 +- apps/webapp/app/services/worker.server.ts | 14 +- .../app/v3/services/batchTriggerV2.server.ts | 362 +++++++++++++++--- packages/core/src/v3/apiClient/index.ts | 6 +- packages/core/src/v3/apiClient/runStream.ts | 1 + packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/runs.ts | 99 +++++ packages/trigger-sdk/src/v3/shared.ts | 47 +-- references/v3-catalog/src/trigger/batch.ts | 112 +++++- 10 files changed, 560 insertions(+), 101 deletions(-) create mode 100644 .changeset/wild-needles-hunt.md diff --git a/.changeset/wild-needles-hunt.md b/.changeset/wild-needles-hunt.md new file mode 100644 index 0000000000..38988f2089 --- /dev/null +++ b/.changeset/wild-needles-hunt.md @@ -0,0 +1,7 @@ +--- +"@trigger.dev/react-hooks": patch +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +Added ability to subscribe to a batch of runs using runs.subscribeToBatch diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index e59932bdcf..782973d981 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -95,12 +95,22 @@ const { action, loader } = createActionApiRoute( return json(batch, { status: 202, headers: $responseHeaders }); } catch (error) { + logger.error("Batch trigger error", { + error: { + message: (error as Error).message, + stack: (error as Error).stack, + }, + }); + if (error instanceof ServiceValidationError) { return json({ error: error.message }, { status: 422 }); } else if (error instanceof OutOfEntitlementError) { return json({ error: error.message }, { status: 422 }); } else if (error instanceof Error) { - return json({ error: error.message }, { status: 500 }); + return json( + { error: error.message }, + { status: 500, headers: { "x-should-retry": "false" } } + ); } return json({ error: "Something went wrong" }, { status: 500 }); diff --git a/apps/webapp/app/services/worker.server.ts b/apps/webapp/app/services/worker.server.ts index 0e996ecb0e..d486335df1 100644 --- a/apps/webapp/app/services/worker.server.ts +++ b/apps/webapp/app/services/worker.server.ts @@ -55,7 +55,7 @@ import { CancelDevSessionRunsServiceOptions, } from "~/v3/services/cancelDevSessionRuns.server"; import { logger } from "./logger.server"; -import { BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server"; +import { BatchProcessingOptions, BatchTriggerV2Service } from "~/v3/services/batchTriggerV2.server"; const workerCatalog = { indexEndpoint: z.object({ @@ -198,11 +198,7 @@ const workerCatalog = { attemptId: z.string(), }), "v3.cancelDevSessionRuns": CancelDevSessionRunsServiceOptions, - "v3.processBatchTaskRun": z.object({ - batchId: z.string(), - currentIndex: z.number().int(), - attemptCount: z.number().int(), - }), + "v3.processBatchTaskRun": BatchProcessingOptions, }; const executionWorkerCatalog = { @@ -739,11 +735,7 @@ function getWorkerQueue() { handler: async (payload, job) => { const service = new BatchTriggerV2Service(); - await service.processBatchTaskRun( - payload.batchId, - payload.currentIndex, - payload.attemptCount - ); + await service.processBatchTaskRun(payload); }, }, }, diff --git a/apps/webapp/app/v3/services/batchTriggerV2.server.ts b/apps/webapp/app/v3/services/batchTriggerV2.server.ts index 41848fea23..3b3250086e 100644 --- a/apps/webapp/app/v3/services/batchTriggerV2.server.ts +++ b/apps/webapp/app/v3/services/batchTriggerV2.server.ts @@ -1,10 +1,11 @@ import { BatchTriggerTaskV2RequestBody, BatchTriggerTaskV2Response, + IOPacket, packetRequiresOffloading, parsePacket, } from "@trigger.dev/core/v3"; -import { BatchTaskRun } from "@trigger.dev/database"; +import { BatchTaskRun, TaskRunAttempt } from "@trigger.dev/database"; import { $transaction, PrismaClientOrTransaction } from "~/db.server"; import { env } from "~/env.server"; import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server"; @@ -20,8 +21,26 @@ import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; import { startActiveSpan } from "../tracer.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server"; +import { z } from "zod"; const PROCESSING_BATCH_SIZE = 50; +const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20; + +const BatchProcessingStrategy = z.enum(["sequential", "parallel"]); + +type BatchProcessingStrategy = z.infer; + +const CURRENT_STRATEGY: BatchProcessingStrategy = "parallel"; + +export const BatchProcessingOptions = z.object({ + batchId: z.string(), + processingId: z.string(), + range: z.object({ start: z.number().int(), count: z.number().int() }), + attemptCount: z.number().int(), + strategy: BatchProcessingStrategy, +}); + +export type BatchProcessingOptions = z.infer; export type BatchTriggerTaskServiceOptions = { idempotencyKey?: string; @@ -261,26 +280,16 @@ export class BatchTriggerV2Service extends BaseService { environment ); - const batch = await $transaction(this._prisma, async (tx) => { - const batch = await tx.batchTaskRun.create({ - data: { - friendlyId: generateFriendlyId("batch"), - runtimeEnvironmentId: environment.id, - idempotencyKey: options.idempotencyKey, - idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, - dependentTaskAttemptId: dependentAttempt?.id, - runCount: body.items.length, - runIds: runs.map((r) => r.id), - payload: payloadPacket.data, - payloadType: payloadPacket.dataType, - options, - }, - }); - - await this.#enqueueBatchTaskRun(batch.id, 0, 0, tx); - - return batch; - }); + const batch = await this.#createAndProcessBatchTaskRun( + batchId, + runs, + payloadPacket, + newRunCount, + environment, + body, + options, + dependentAttempt ?? undefined + ); if (!batch) { throw new Error("Failed to create batch"); @@ -296,6 +305,157 @@ export class BatchTriggerV2Service extends BaseService { ); } + async #createAndProcessBatchTaskRun( + batchId: string, + runs: Array<{ + id: string; + isCached: boolean; + idempotencyKey: string | undefined; + taskIdentifier: string; + }>, + payloadPacket: IOPacket, + newRunCount: number, + environment: AuthenticatedEnvironment, + body: BatchTriggerTaskV2RequestBody, + options: BatchTriggerTaskServiceOptions = {}, + dependentAttempt?: TaskRunAttempt + ) { + if (newRunCount <= ASYNC_BATCH_PROCESS_SIZE_THRESHOLD) { + const batch = await this._prisma.batchTaskRun.create({ + data: { + friendlyId: batchId, + runtimeEnvironmentId: environment.id, + idempotencyKey: options.idempotencyKey, + idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, + dependentTaskAttemptId: dependentAttempt?.id, + runCount: body.items.length, + runIds: runs.map((r) => r.id), + payload: payloadPacket.data, + payloadType: payloadPacket.dataType, + options, + }, + }); + + const result = await this.#processBatchTaskRunItems( + batch, + environment, + 0, + PROCESSING_BATCH_SIZE, + body.items, + options + ); + + switch (result.status) { + case "COMPLETE": { + logger.debug("[BatchTriggerV2][call] Batch inline processing complete", { + batchId: batch.friendlyId, + currentIndex: 0, + }); + + return batch; + } + case "INCOMPLETE": { + logger.debug("[BatchTriggerV2][call] Batch inline processing incomplete", { + batchId: batch.friendlyId, + currentIndex: result.workingIndex, + }); + + // If processing inline does not finish for some reason, enqueue processing the rest of the batch + await this.#enqueueBatchTaskRun({ + batchId: batch.id, + processingId: "0", + range: { + start: result.workingIndex, + count: PROCESSING_BATCH_SIZE, + }, + attemptCount: 0, + strategy: "sequential", + }); + + return batch; + } + case "ERROR": { + logger.error("[BatchTriggerV2][call] Batch inline processing error", { + batchId: batch.friendlyId, + currentIndex: result.workingIndex, + error: result.error, + }); + + await this.#enqueueBatchTaskRun({ + batchId: batch.id, + processingId: "0", + range: { + start: result.workingIndex, + count: PROCESSING_BATCH_SIZE, + }, + attemptCount: 0, + strategy: "sequential", + }); + + return batch; + } + } + } else { + return await $transaction(this._prisma, async (tx) => { + const batch = await tx.batchTaskRun.create({ + data: { + friendlyId: batchId, + runtimeEnvironmentId: environment.id, + idempotencyKey: options.idempotencyKey, + idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, + dependentTaskAttemptId: dependentAttempt?.id, + runCount: body.items.length, + runIds: runs.map((r) => r.id), + payload: payloadPacket.data, + payloadType: payloadPacket.dataType, + options, + }, + }); + + switch (CURRENT_STRATEGY) { + case "sequential": { + await this.#enqueueBatchTaskRun({ + batchId: batch.id, + processingId: batchId, + range: { start: 0, count: PROCESSING_BATCH_SIZE }, + attemptCount: 0, + strategy: CURRENT_STRATEGY, + }); + + break; + } + case "parallel": { + const ranges = Array.from({ + length: Math.ceil(newRunCount / PROCESSING_BATCH_SIZE), + }).map((_, index) => ({ + start: index * PROCESSING_BATCH_SIZE, + count: PROCESSING_BATCH_SIZE, + })); + + await Promise.all( + ranges.map((range, index) => + this.#enqueueBatchTaskRun( + { + batchId: batch.id, + processingId: `${index}`, + range, + attemptCount: 0, + strategy: CURRENT_STRATEGY, + }, + tx + ) + ) + ); + + break; + } + } + + return batch; + }); + } + } + async #respondWithExistingBatch( batch: BatchTaskRun, environment: AuthenticatedEnvironment @@ -332,17 +492,15 @@ export class BatchTriggerV2Service extends BaseService { }; } - async processBatchTaskRun(batchId: string, currentIndex: number, attemptCount: number) { + async processBatchTaskRun(options: BatchProcessingOptions) { logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch", { - batchId, - currentIndex, - attemptCount, + options, }); - const $attemptCount = attemptCount + 1; + const $attemptCount = options.attemptCount + 1; const batch = await this._prisma.batchTaskRun.findFirst({ - where: { id: batchId }, + where: { id: options.batchId }, include: { runtimeEnvironment: { include: { @@ -358,10 +516,10 @@ export class BatchTriggerV2Service extends BaseService { } // Check to make sure the currentIndex is not greater than the runCount - if (currentIndex >= batch.runCount) { + if (options.range.start >= batch.runCount) { logger.debug("[BatchTriggerV2][processBatchTaskRun] currentIndex is greater than runCount", { + options, batchId: batch.friendlyId, - currentIndex, runCount: batch.runCount, attemptCount: $attemptCount, }); @@ -382,8 +540,8 @@ export class BatchTriggerV2Service extends BaseService { if (!payload) { logger.debug("[BatchTriggerV2][processBatchTaskRun] Failed to parse payload", { + options, batchId: batch.friendlyId, - currentIndex, attemptCount: $attemptCount, }); @@ -392,35 +550,126 @@ export class BatchTriggerV2Service extends BaseService { // Skip zod parsing const $payload = payload as BatchTriggerTaskV2RequestBody["items"]; + const $options = batch.options as BatchTriggerTaskServiceOptions; + + const result = await this.#processBatchTaskRunItems( + batch, + batch.runtimeEnvironment, + options.range.start, + options.range.count, + $payload, + $options + ); + + switch (result.status) { + case "COMPLETE": { + logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing complete", { + options, + batchId: batch.friendlyId, + attemptCount: $attemptCount, + }); + + return; + } + case "INCOMPLETE": { + logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing incomplete", { + batchId: batch.friendlyId, + currentIndex: result.workingIndex, + attemptCount: $attemptCount, + }); + + // Only enqueue the next batch task run if the strategy is sequential + // if the strategy is parallel, we will already have enqueued the next batch task run + if (options.strategy === "sequential") { + await this.#enqueueBatchTaskRun({ + batchId: batch.id, + processingId: options.processingId, + range: { + start: result.workingIndex, + count: options.range.count, + }, + attemptCount: 0, + strategy: options.strategy, + }); + } + + return; + } + case "ERROR": { + logger.error("[BatchTriggerV2][processBatchTaskRun] Batch processing error", { + batchId: batch.friendlyId, + currentIndex: result.workingIndex, + error: result.error, + attemptCount: $attemptCount, + }); + + // if the strategy is sequential, we will requeue processing with a count of the PROCESSING_BATCH_SIZE + // if the strategy is parallel, we will requeue processing with a range starting at the workingIndex and a count that is the remainder of this "slice" of the batch + if (options.strategy === "sequential") { + await this.#enqueueBatchTaskRun({ + batchId: batch.id, + processingId: options.processingId, + range: { + start: result.workingIndex, + count: options.range.count, // This will be the same as the original count + }, + attemptCount: $attemptCount, + strategy: options.strategy, + }); + } else { + await this.#enqueueBatchTaskRun({ + batchId: batch.id, + processingId: options.processingId, + range: { + start: result.workingIndex, + // This will be the remainder of the slice + // for example if the original range was 0-50 and the workingIndex is 25, the new range will be 25-25 + // if the original range was 51-100 and the workingIndex is 75, the new range will be 75-25 + count: options.range.count - result.workingIndex - options.range.start, + }, + attemptCount: $attemptCount, + strategy: options.strategy, + }); + } + + return; + } + } + } + async #processBatchTaskRunItems( + batch: BatchTaskRun, + environment: AuthenticatedEnvironment, + currentIndex: number, + batchSize: number, + items: BatchTriggerTaskV2RequestBody["items"], + options?: BatchTriggerTaskServiceOptions + ): Promise< + | { status: "COMPLETE" } + | { status: "INCOMPLETE"; workingIndex: number } + | { status: "ERROR"; error: string; workingIndex: number } + > { // Grab the next PROCESSING_BATCH_SIZE runIds - const runIds = batch.runIds.slice(currentIndex, currentIndex + PROCESSING_BATCH_SIZE); + const runIds = batch.runIds.slice(currentIndex, currentIndex + batchSize); logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch items", { batchId: batch.friendlyId, currentIndex, runIds, - attemptCount: $attemptCount, runCount: batch.runCount, }); // Combine the "window" between currentIndex and currentIndex + PROCESSING_BATCH_SIZE with the runId and the item in the payload which is an array const itemsToProcess = runIds.map((runId, index) => ({ runId, - item: $payload[index + currentIndex], + item: items[index + currentIndex], })); let workingIndex = currentIndex; for (const item of itemsToProcess) { try { - await this.#processBatchTaskRunItem( - batch, - batch.runtimeEnvironment, - item, - workingIndex, - batch.options as BatchTriggerTaskServiceOptions - ); + await this.#processBatchTaskRunItem(batch, environment, item, workingIndex, options); workingIndex++; } catch (error) { @@ -430,17 +679,20 @@ export class BatchTriggerV2Service extends BaseService { error, }); - // Requeue the batch to try again - await this.#enqueueBatchTaskRun(batch.id, workingIndex, $attemptCount); - return; + return { + status: "ERROR", + error: error instanceof Error ? error.message : String(error), + workingIndex, + }; } } // if there are more items to process, requeue the batch if (workingIndex < batch.runCount) { - await this.#enqueueBatchTaskRun(batch.id, workingIndex, 0); - return; + return { status: "INCOMPLETE", workingIndex }; } + + return { status: "COMPLETE" }; } async #processBatchTaskRunItem( @@ -492,21 +744,11 @@ export class BatchTriggerV2Service extends BaseService { }); } - async #enqueueBatchTaskRun( - batchId: string, - currentIndex: number = 0, - attemptCount: number = 0, - tx?: PrismaClientOrTransaction - ) { - await workerQueue.enqueue( - "v3.processBatchTaskRun", - { - batchId, - currentIndex, - attemptCount, - }, - { tx, jobKey: `process-batch:${batchId}` } - ); + async #enqueueBatchTaskRun(options: BatchProcessingOptions, tx?: PrismaClientOrTransaction) { + await workerQueue.enqueue("v3.processBatchTaskRun", options, { + tx, + jobKey: `BatchTriggerV2Service.process:${options.batchId}:${options.processingId}`, + }); } async #handlePayloadPacket( diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index b768480a6a..5a195dcab9 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -43,6 +43,7 @@ import { ApiError } from "./errors.js"; import { AnyRunShape, RealtimeRun, + AnyRealtimeRun, RunShape, RunStreamCallback, RunSubscription, @@ -84,10 +85,10 @@ export type TriggerApiRequestOptions = ApiRequestOptions & { const DEFAULT_ZOD_FETCH_OPTIONS: ZodFetchOptions = { retry: { - maxAttempts: 3, + maxAttempts: 5, minTimeoutInMs: 1000, maxTimeoutInMs: 30_000, - factor: 2, + factor: 1.6, randomize: false, }, }; @@ -97,6 +98,7 @@ export type { AnyRunShape, ApiRequestOptions, RealtimeRun, + AnyRealtimeRun, RunShape, RunStreamCallback, RunSubscription, diff --git a/packages/core/src/v3/apiClient/runStream.ts b/packages/core/src/v3/apiClient/runStream.ts index d25900595b..dd75458a86 100644 --- a/packages/core/src/v3/apiClient/runStream.ts +++ b/packages/core/src/v3/apiClient/runStream.ts @@ -43,6 +43,7 @@ export type AnyRunShape = RunShape; export type TaskRunShape = RunShape>; export type RealtimeRun = TaskRunShape; +export type AnyRealtimeRun = RealtimeRun; export type RunStreamCallback = ( run: RunShape diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 67d6a27234..40060920c9 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -40,6 +40,7 @@ export { type AnyRunShape, type TaskRunShape, type RealtimeRun, + type AnyRealtimeRun, type RetrieveRunResult, type AnyRetrieveRunResult, } from "./runs.js"; diff --git a/packages/trigger-sdk/src/v3/runs.ts b/packages/trigger-sdk/src/v3/runs.ts index f212dc36d1..1e6ee60c0c 100644 --- a/packages/trigger-sdk/src/v3/runs.ts +++ b/packages/trigger-sdk/src/v3/runs.ts @@ -9,6 +9,7 @@ import type { RetrieveRunResult, RunShape, RealtimeRun, + AnyRealtimeRun, RunSubscription, TaskRunShape, } from "@trigger.dev/core/v3"; @@ -36,6 +37,7 @@ export type { RunShape, TaskRunShape, RealtimeRun, + AnyRealtimeRun, }; export const runs = { @@ -47,6 +49,7 @@ export const runs = { poll, subscribeToRun, subscribeToRunsWithTag, + subscribeToBatch: subscribeToRunsInBatch, }; export type ListRunsItem = ListRunResponseItem; @@ -331,6 +334,35 @@ async function poll( ); } +/** + * Subscribes to real-time updates for a specific run. + * + * This function allows you to receive real-time updates whenever a run changes, including: + * - Status changes in the run lifecycle + * - Tag additions or removals + * - Metadata updates + * + * @template TRunId - The type parameter extending AnyRunHandle, AnyTask, or string + * @param {RunId} runId - The ID of the run to subscribe to. Can be a string ID, RunHandle, or Task + * @returns {RunSubscription>} An async iterator that yields updated run objects + * + * @example + * ```ts + * // Subscribe using a run handle + * const handle = await tasks.trigger("my-task", { some: "data" }); + * for await (const run of runs.subscribeToRun(handle.id)) { + * console.log("Run updated:", run); + * } + * + * // Subscribe with type safety + * for await (const run of runs.subscribeToRun(runId)) { + * console.log("Payload:", run.payload.some); + * if (run.output) { + * console.log("Output:", run.output); + * } + * } + * ``` + */ function subscribeToRun( runId: RunId ): RunSubscription> { @@ -341,6 +373,36 @@ function subscribeToRun( return apiClient.subscribeToRun($runId); } +/** + * Subscribes to real-time updates for all runs that have specific tags. + * + * This function allows you to monitor multiple runs simultaneously by filtering on tags. + * You'll receive updates whenever any run with the specified tag(s) changes. + * + * @template TTasks - The type parameter extending AnyTask for type-safe payload and output + * @param {string | string[]} tag - A single tag or array of tags to filter runs + * @returns {RunSubscription>} An async iterator that yields updated run objects + * + * @example + * ```ts + * // Subscribe to runs with a single tag + * for await (const run of runs.subscribeToRunsWithTag("user:1234")) { + * console.log("Run updated:", run); + * } + * + * // Subscribe with multiple tags and type safety + * for await (const run of runs.subscribeToRunsWithTag(["tag1", "tag2"])) { + * switch (run.taskIdentifier) { + * case "my-task": + * console.log("MyTask output:", run.output.foo); + * break; + * case "other-task": + * console.log("OtherTask output:", run.output.bar); + * break; + * } + * } + * ``` + */ function subscribeToRunsWithTag( tag: string | string[] ): RunSubscription> { @@ -348,3 +410,40 @@ function subscribeToRunsWithTag( return apiClient.subscribeToRunsWithTag>(tag); } + +/** + * Subscribes to real-time updates for all runs within a specific batch. + * + * Use this function when you've triggered multiple runs using `batchTrigger` and want + * to monitor all runs in that batch. You'll receive updates whenever any run in the batch changes. + * + * @template TTasks - The type parameter extending AnyTask for type-safe payload and output + * @param {string} batchId - The ID of the batch to subscribe to + * @returns {RunSubscription>} An async iterator that yields updated run objects + * + * @example + * ```ts + * // Subscribe to all runs in a batch + * for await (const run of runs.subscribeToRunsInBatch("batch-123")) { + * console.log("Batch run updated:", run); + * } + * + * // Subscribe with type safety + * for await (const run of runs.subscribeToRunsInBatch("batch-123")) { + * console.log("Run payload:", run.payload); + * if (run.output) { + * console.log("Run output:", run.output); + * } + * } + * ``` + * + * @note The run objects received will include standard fields like id, status, payload, output, + * createdAt, updatedAt, tags, and more. See the Run object documentation for full details. + */ +function subscribeToRunsInBatch( + batchId: string +): RunSubscription> { + const apiClient = apiClientManager.clientOrThrow(); + + return apiClient.subscribeToBatch>(batchId); +} diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index d9f83e4d68..97527bd1d0 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -557,18 +557,13 @@ async function trigger_internal( name, tracer, icon: "trigger", - attributes: { - [SEMATTRS_MESSAGING_OPERATION]: "publish", - ["messaging.client_id"]: taskContext.worker?.id, - [SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev", - }, onResponseBody: (body, span) => { body && typeof body === "object" && !Array.isArray(body) && "id" in body && typeof body.id === "string" && - span.setAttribute("messaging.message.id", body.id); + span.setAttribute("runId", body.id); }, ...requestOptions, } @@ -627,10 +622,26 @@ async function batchTrigger_internal( name, tracer, icon: "trigger", - attributes: { - [SEMATTRS_MESSAGING_OPERATION]: "publish", - ["messaging.client_id"]: taskContext.worker?.id, - [SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev", + onResponseBody(body, span) { + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "id" in body && + typeof body.id === "string" + ) { + span.setAttribute("batchId", body.id); + } + + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "runs" in body && + Array.isArray(body.runs) + ) { + span.setAttribute("runCount", body.runs.length); + } }, ...requestOptions, } @@ -693,7 +704,7 @@ async function triggerAndWait_internal( requestOptions ); - span.setAttribute("messaging.message.id", response.id); + span.setAttribute("runId", response.id); const result = await runtime.waitForTask({ id: response.id, @@ -705,11 +716,6 @@ async function triggerAndWait_internal( { kind: SpanKind.PRODUCER, attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "trigger", - [SEMATTRS_MESSAGING_OPERATION]: "publish", - ["messaging.client_id"]: taskContext.worker?.id, - [SEMATTRS_MESSAGING_DESTINATION]: id, - [SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev", ...accessoryAttributes({ items: [ { @@ -776,7 +782,8 @@ async function batchTriggerAndWait_internal( requestOptions ); - span.setAttribute("messaging.message.id", response.id); + span.setAttribute("batchId", response.id); + span.setAttribute("runCount", response.runs.length); const result = await runtime.waitForBatch({ id: response.id, @@ -794,12 +801,6 @@ async function batchTriggerAndWait_internal( { kind: SpanKind.PRODUCER, attributes: { - [SemanticInternalAttributes.STYLE_ICON]: "trigger", - ["messaging.batch.message_count"]: items.length, - [SEMATTRS_MESSAGING_OPERATION]: "publish", - ["messaging.client_id"]: taskContext.worker?.id, - [SEMATTRS_MESSAGING_DESTINATION]: id, - [SEMATTRS_MESSAGING_SYSTEM]: "trigger.dev", ...accessoryAttributes({ items: [ { diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index 1bbffab76e..e8838d8a91 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -1,6 +1,16 @@ -import { auth, logger, runs, task, tasks, wait } from "@trigger.dev/sdk/v3"; +import { + AnyRealtimeRun, + auth, + logger, + RealtimeRun, + runs, + task, + tasks, + wait, +} from "@trigger.dev/sdk/v3"; import assert from "node:assert"; import { randomUUID } from "node:crypto"; +import { setTimeout } from "node:timers/promises"; export const batchParentTask = task({ id: "batch-parent-task", @@ -109,7 +119,28 @@ export const taskThatFails = task({ export const batchV2TestTask = task({ id: "batch-v2-test", + retry: { + maxAttempts: 1, + }, run: async () => { + // First lets try triggering with too many items + try { + await tasks.batchTrigger( + "batch-v2-test-child", + Array.from({ length: 501 }, (_, i) => ({ + payload: { foo: `bar${i}` }, + })) + ); + + assert.fail("Batch trigger should have failed"); + } catch (error: any) { + assert.equal( + error.message, + '400 "Batch size of 501 is too large. Maximum allowed batch size is 500."', + "Batch trigger failed with wrong error" + ); + } + // TODO tests: // tasks.batchTrigger // tasks.batchTriggerAndWait @@ -279,6 +310,8 @@ export const batchV2TestTask = task({ assert.ok(response6.runs[0].idempotencyKey, "response6: runs[0] Idempotent key is invalid"); assert.ok(response6.runs[1].idempotencyKey, "response6: runs[1] Idempotent key is invalid"); + await setTimeout(1000); + const response7 = await batchV2TestChild.batchTrigger([ { payload: { foo: "bar" }, options: { idempotencyKey: idempotencyKeyChild1 } }, { payload: { foo: "baz" }, options: { idempotencyKey: idempotencyKeyChild2 } }, @@ -357,9 +390,9 @@ export const batchV2TestTask = task({ "response9: runs[1] result is invalid" ); - // Now batchTriggerAndWait with 100 items + // Now batchTriggerAndWait with 21 items const response10 = await batchV2TestChild.batchTriggerAndWait( - Array.from({ length: 100 }, (_, i) => ({ + Array.from({ length: 21 }, (_, i) => ({ payload: { foo: `bar${i}` }, })) ); @@ -367,7 +400,7 @@ export const batchV2TestTask = task({ logger.debug("Response 10", { response10 }); assert.match(response10.id, /^batch_[a-z0-9]{21}$/, "response10: Batch ID is invalid"); - assert.equal(response10.runs.length, 100, "response10: Items length is invalid"); + assert.equal(response10.runs.length, 21, "response10: Items length is invalid"); // Now repeat the first few tests using `tasks.batchTrigger`: const response11 = await tasks.batchTrigger("batch-v2-test-child", [ @@ -391,11 +424,82 @@ export const batchV2TestTask = task({ undefined, "response11: runs[0] Idempotent key is invalid" ); + + // Now use tasks.batchTrigger with 100 items + const response12 = await tasks.batchTrigger( + "batch-v2-test-child", + Array.from({ length: 100 }, (_, i) => ({ + payload: { foo: `bar${i}` }, + })) + ); + + const response12Start = performance.now(); + + logger.debug("Response 12", { response12 }); + + assert.match(response12.batchId, /^batch_[a-z0-9]{21}$/, "response12: Batch ID is invalid"); + assert.equal(response12.runs.length, 100, "response12: Items length is invalid"); + + const runsById: Map = new Map(); + + for await (const run of runs.subscribeToBatch(response12.batchId)) { + runsById.set(run.id, run); + + // Break if we have received all runs + if (runsById.size === response12.runs.length) { + break; + } + } + + const response12End = performance.now(); + + logger.debug("Response 12 time", { time: response12End - response12Start }); + + logger.debug("All runs", { runsById: Object.fromEntries(runsById) }); + + assert.equal(runsById.size, 100, "All runs were not received"); + + // Now use tasks.batchTrigger with 100 items + const response13 = await tasks.batchTrigger( + "batch-v2-test-child", + Array.from({ length: 500 }, (_, i) => ({ + payload: { foo: `bar${i}` }, + })) + ); + + const response13Start = performance.now(); + + logger.debug("Response 13", { response13 }); + + assert.match(response13.batchId, /^batch_[a-z0-9]{21}$/, "response13: Batch ID is invalid"); + assert.equal(response13.runs.length, 500, "response13: Items length is invalid"); + + runsById.clear(); + + for await (const run of runs.subscribeToBatch(response13.batchId)) { + runsById.set(run.id, run); + + // Break if we have received all runs + if (runsById.size === response13.runs.length) { + break; + } + } + + const response13End = performance.now(); + + logger.debug("Response 13 time", { time: response13End - response13Start }); + + logger.debug("All runs", { runsById: Object.fromEntries(runsById) }); + + assert.equal(runsById.size, 500, "All runs were not received"); }, }); export const batchV2TestChild = task({ id: "batch-v2-test-child", + queue: { + concurrencyLimit: 10, + }, run: async (payload: any) => { return payload; }, From e8cfe88217b910ece863564dd4e965d0aef3347e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 26 Nov 2024 13:46:11 +0000 Subject: [PATCH 15/44] Added a runId filter, and WIP for batchId filter --- .../app/components/runs/v3/RunFilters.tsx | 141 +++++++++++++++++- .../presenters/v3/RunListPresenter.server.ts | 9 ++ .../route.tsx | 6 + 3 files changed, 153 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index cd5c2ac536..fd4a91036f 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -3,6 +3,7 @@ import { ArrowPathIcon, CalendarIcon, CpuChipIcon, + FingerPrintIcon, InboxStackIcon, TagIcon, TrashIcon, @@ -53,13 +54,16 @@ import { import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; import { DateTime } from "~/components/primitives/DateTime"; import { BulkActionStatusCombo } from "./BulkAction"; -import { type loader } from "~/routes/resources.projects.$projectParam.runs.tags"; +import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; import { useProject } from "~/hooks/useProject"; import { Spinner } from "~/components/primitives/Spinner"; import { matchSorter } from "match-sorter"; import { DateField } from "~/components/primitives/DateField"; import { Label } from "~/components/primitives/Label"; import { Switch } from "~/components/primitives/Switch"; +import { Input } from "~/components/primitives/Input"; +import { Hint } from "~/components/primitives/Hint"; +import { FormError } from "~/components/primitives/FormError"; export const TaskAttemptStatus = z.enum(allTaskRunStatuses); @@ -91,6 +95,8 @@ export const TaskRunListSearchFilters = z.object({ from: z.coerce.number().optional(), to: z.coerce.number().optional(), showChildTasks: z.coerce.boolean().optional(), + batchId: z.string().optional(), + runId: z.string().optional(), }); export type TaskRunListSearchFilters = z.infer; @@ -162,6 +168,7 @@ const filterTypes = [ { name: "created", title: "Created", icon: }, { name: "bulk", title: "Bulk action", icon: }, { name: "daterange", title: "Custom date range", icon: }, + { name: "run", title: "Run id", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -233,6 +240,7 @@ function FilterMenuProvider({ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) { return ( <> + @@ -270,6 +278,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "tags": return props.setFilterType(undefined)} {...props} />; + case "run": + return props.setFilterType(undefined)} {...props} />; } } @@ -705,7 +715,7 @@ function TagsDropdown({ }); }; - const fetcher = useFetcher(); + const fetcher = useFetcher(); useEffect(() => { const searchParams = new URLSearchParams(); @@ -1040,7 +1050,15 @@ function CustomDateRangeDropdown({ -
@@ -1121,6 +1139,123 @@ function ShowChildTasksToggle() { ); } +function RunIdDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const runIdValue = value("runId"); + + const [runId, setRunId] = useState(runIdValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + runId: runId === "" ? undefined : runId?.toString(), + }); + + setOpen(false); + }, [runId, replace]); + + let error: string | undefined = undefined; + if (runId) { + if (!runId.startsWith("run_")) { + error = "Run IDs start with 'run_'"; + } else if (runId.length !== 25) { + error = "Run IDs are 25 characters long"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setRunId(e.target.value)} + variant="small" + className="w-[27ch] font-mono" + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedRunIdFilter() { + const { value, del } = useSearchParams(); + + if (value("runId") === undefined) { + return null; + } + + const runId = value("runId"); + + return ( + + {(search, setSearch) => ( + }> + del(["runId", "cursor", "direction"])} + /> +
+ } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + function dateFromString(value: string | undefined | null): Date | undefined { if (!value) return; diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index 4223f310f2..f5c0ab42b4 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -23,6 +23,8 @@ export type RunListOptions = { to?: number; isTest?: boolean; rootOnly?: boolean; + batchId?: string; + runId?: string; //pagination direction?: Direction; cursor?: string; @@ -49,6 +51,8 @@ export class RunListPresenter extends BasePresenter { bulkId, isTest, rootOnly, + batchId, + runId, from, to, direction = "forward", @@ -68,6 +72,8 @@ export class RunListPresenter extends BasePresenter { to !== undefined || (scheduleId !== undefined && scheduleId !== "") || (tags !== undefined && tags.length > 0) || + batchId !== undefined || + runId !== undefined || typeof isTest === "boolean" || rootOnly === true; @@ -172,6 +178,7 @@ export class RunListPresenter extends BasePresenter { tags: null | string[]; depth: number; rootTaskRunId: string | null; + batchId: string | null; }[] >` SELECT @@ -214,6 +221,7 @@ WHERE : Prisma.empty } -- filters + ${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty} ${ restrictToRunIds ? restrictToRunIds.length === 0 @@ -257,6 +265,7 @@ WHERE : Prisma.empty } ${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty} + ${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty} GROUP BY tr.id, bw.version ORDER BY diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx index 80036e45ba..b736a1dda7 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx @@ -66,6 +66,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { from: url.searchParams.get("from") ?? undefined, to: url.searchParams.get("to") ?? undefined, showChildTasks: url.searchParams.get("showChildTasks") === "true", + runId: url.searchParams.get("runId") ?? undefined, + batchId: url.searchParams.get("batchId") ?? undefined, }; const { tasks, @@ -80,6 +82,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { cursor, direction, showChildTasks, + runId, + batchId, } = TaskRunListSearchFilters.parse(s); const project = await findProjectBySlug(organizationSlug, projectParam, userId); @@ -101,6 +105,8 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { bulkId, from, to, + batchId, + runId, rootOnly: !showChildTasks, direction: direction, cursor: cursor, From b0c3c418669c44ccd5094b4bfa5f1dc9f88a367c Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 26 Nov 2024 17:19:13 +0000 Subject: [PATCH 16/44] WIP triggerAll --- packages/core/src/package.json | 3 + packages/core/src/v3/types/tasks.ts | 21 ++++- packages/trigger-sdk/src/package.json | 3 + packages/trigger-sdk/src/v3/runs.ts | 5 +- packages/trigger-sdk/src/v3/shared.ts | 103 ++++++++++++++++++++- packages/trigger-sdk/src/v3/tasks.ts | 4 + references/v3-catalog/src/trigger/batch.ts | 98 ++++++++++++++++++++ 7 files changed, 225 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/package.json create mode 100644 packages/trigger-sdk/src/package.json diff --git a/packages/core/src/package.json b/packages/core/src/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/core/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index b375d059f4..486a4df2ed 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -441,6 +441,12 @@ export type BatchTriggerAndWaitItem = { options?: TriggerAndWaitOptions; }; +export type BatchAllItem = { + task: TRunTypes["taskIdentifier"]; + payload: TRunTypes["payload"]; + options?: TriggerOptions; +}; + export interface Task { /** * The id of the task. @@ -568,6 +574,11 @@ export type TaskIdentifier = TTask extends Task = TTask extends { id: TIdentifier } ? TTask : never; + export type TriggerJwtOptions = { /** * The expiration time of the JWT. This can be a string like "1h" or a Date object. @@ -731,6 +742,8 @@ export type InferRunTypes = T extends RunHandle< infer TPayload, infer TOutput > + ? RunTypes + : T extends BatchedRunHandle ? RunTypes : T extends Task ? RunTypes @@ -742,8 +755,6 @@ export type RunHandleFromTypes = RunHandle< TRunTypes["output"] >; -export type BatchRunHandleFromTypes = BatchRunHandle< - TRunTypes["taskIdentifier"], - TRunTypes["payload"], - TRunTypes["output"] ->; +export type BatchRunHandleFromTypes = TRunTypes extends AnyRunTypes + ? BatchRunHandle + : never; diff --git a/packages/trigger-sdk/src/package.json b/packages/trigger-sdk/src/package.json new file mode 100644 index 0000000000..3dbc1ca591 --- /dev/null +++ b/packages/trigger-sdk/src/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/packages/trigger-sdk/src/v3/runs.ts b/packages/trigger-sdk/src/v3/runs.ts index 1e6ee60c0c..3ae9333a4f 100644 --- a/packages/trigger-sdk/src/v3/runs.ts +++ b/packages/trigger-sdk/src/v3/runs.ts @@ -12,6 +12,7 @@ import type { AnyRealtimeRun, RunSubscription, TaskRunShape, + AnyBatchedRunHandle, } from "@trigger.dev/core/v3"; import { ApiPromise, @@ -153,7 +154,7 @@ function listRunsRequestOptions( } // Extract out the expected type of the id, can be either a string or a RunHandle -type RunId = TRunId extends AnyRunHandle +type RunId = TRunId extends AnyRunHandle | AnyBatchedRunHandle ? TRunId : TRunId extends AnyTask ? string @@ -161,7 +162,7 @@ type RunId = TRunId extends AnyRunHandle ? TRunId : never; -function retrieveRun( +function retrieveRun( runId: RunId, requestOptions?: ApiRequestOptions ): ApiPromise> { diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 97527bd1d0..fd15cb026c 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -1,9 +1,4 @@ import { SpanKind } from "@opentelemetry/api"; -import { - SEMATTRS_MESSAGING_DESTINATION, - SEMATTRS_MESSAGING_OPERATION, - SEMATTRS_MESSAGING_SYSTEM, -} from "@opentelemetry/semantic-conventions"; import { SerializableJson } from "@trigger.dev/core"; import { accessoryAttributes, @@ -28,6 +23,7 @@ import { TaskRunContext, TaskRunExecutionResult, TaskRunPromise, + TaskFromIdentifier, } from "@trigger.dev/core/v3"; import { PollOptions, runs } from "./runs.js"; import { tracer } from "./tracer.js"; @@ -36,6 +32,7 @@ import type { AnyRunHandle, AnyRunTypes, AnyTask, + BatchAllItem, BatchItem, BatchResult, BatchRunHandle, @@ -92,6 +89,7 @@ export type { TaskPayload, TaskRunResult, TriggerOptions, + TaskFromIdentifier, }; export { SubtaskUnwrapError, TaskRunPromise }; @@ -516,6 +514,19 @@ export async function batchTrigger( ); } +export async function triggerAll( + items: Array>>, + options?: BatchTriggerOptions, + requestOptions?: TriggerApiRequestOptions +): Promise>> { + return await triggerAll_internal>( + "tasks.triggerAll()", + items, + options, + requestOptions + ); +} + async function trigger_internal( name: string, id: TRunTypes["taskIdentifier"], @@ -815,6 +826,88 @@ async function batchTriggerAndWait_internal( ); } +async function triggerAll_internal( + name: string, + items: Array>, + options?: BatchTriggerOptions, + requestOptions?: TriggerApiRequestOptions, + queue?: QueueOptions +): Promise> { + const apiClient = apiClientManager.clientOrThrow(); + + const response = await apiClient.batchTriggerV2( + { + items: await Promise.all( + items.map(async (item) => { + const payloadPacket = await stringifyIO(item.payload); + + return { + task: item.task, + payload: payloadPacket.data, + options: { + queue: item.options?.queue ?? queue, + concurrencyKey: item.options?.concurrencyKey, + test: taskContext.ctx?.run.isTest, + payloadType: payloadPacket.dataType, + idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey), + idempotencyKeyTTL: item.options?.idempotencyKeyTTL, + delay: item.options?.delay, + ttl: item.options?.ttl, + tags: item.options?.tags, + maxAttempts: item.options?.maxAttempts, + parentAttempt: taskContext.ctx?.attempt.id, + metadata: item.options?.metadata, + maxDuration: item.options?.maxDuration, + }, + }; + }) + ), + }, + { + spanParentAsLink: true, + idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKeyTTL: options?.idempotencyKeyTTL, + }, + { + name, + tracer, + icon: "trigger", + onResponseBody(body, span) { + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "id" in body && + typeof body.id === "string" + ) { + span.setAttribute("batchId", body.id); + } + + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "runs" in body && + Array.isArray(body.runs) + ) { + span.setAttribute("runCount", body.runs.length); + } + }, + ...requestOptions, + } + ); + + const handle = { + batchId: response.id, + isCached: response.isCached, + idempotencyKey: response.idempotencyKey, + runs: response.runs, + publicAccessToken: response.publicAccessToken, + }; + + return handle as BatchRunHandleFromTypes; +} + async function handleBatchTaskRunExecutionResult( items: Array, taskIdentifier: string diff --git a/packages/trigger-sdk/src/v3/tasks.ts b/packages/trigger-sdk/src/v3/tasks.ts index 55e1bc0217..bb65417dd7 100644 --- a/packages/trigger-sdk/src/v3/tasks.ts +++ b/packages/trigger-sdk/src/v3/tasks.ts @@ -8,6 +8,7 @@ import { trigger, triggerAndPoll, triggerAndWait, + triggerAll, } from "./shared.js"; export { SubtaskUnwrapError }; @@ -26,6 +27,7 @@ import type { TaskPayload, TriggerOptions, TaskRunResult, + TaskFromIdentifier, } from "./shared.js"; export type { @@ -42,6 +44,7 @@ export type { TaskPayload, TriggerOptions, TaskRunResult, + TaskFromIdentifier, }; /** Creates a task that can be triggered @@ -74,4 +77,5 @@ export const tasks = { batchTrigger, triggerAndWait, batchTriggerAndWait, + triggerAll, }; diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index e8838d8a91..56368ed640 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -5,6 +5,7 @@ import { RealtimeRun, runs, task, + TaskFromIdentifier, tasks, wait, } from "@trigger.dev/sdk/v3"; @@ -117,6 +118,103 @@ export const taskThatFails = task({ }, }); +export type Expect = T; +export type ExpectTrue = T; +export type ExpectFalse = T; +export type IsTrue = T; +export type IsFalse = T; + +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; +export type NotEqual = true extends Equal ? false : true; + +// https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360 +export type IsAny = 0 extends 1 & T ? true : false; +export type NotAny = true extends IsAny ? false : true; + +export type Debug = { [K in keyof T]: T[K] }; +export type MergeInsertions = T extends object ? { [K in keyof T]: MergeInsertions } : T; + +export type Alike = Equal, MergeInsertions>; + +export type ExpectExtends = EXPECTED extends VALUE ? true : false; +export type ExpectValidArgs< + FUNC extends (...args: any[]) => any, + ARGS extends any[], +> = ARGS extends Parameters ? true : false; + +export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( + k: infer I +) => void + ? I + : never; + +export const allV2TestTask = task({ + id: "all-v2-test", + retry: { + maxAttempts: 1, + }, + run: async () => { + const response1 = await tasks.triggerAll([ + { task: "all-v2-test-child-1", payload: { child1: "foo" } }, + { task: "all-v2-test-child-2", payload: { child2: "bar" } }, + { task: "all-v2-test-child-1", payload: { child1: "baz" } }, + ]); + + // This would have the type of the first task above + const firstRunHandle = response1.runs[0]; + const run1 = await runs.retrieve(firstRunHandle); + + type Run1Payload = Expect>; + + for (const run of response1.runs) { + switch (run.taskIdentifier) { + case "all-v2-test-child-1": { + const run1 = await runs.retrieve(run); + + type Run1Payload = Expect>; + type Run1Output = Expect>; + + break; + } + case "all-v2-test-child-2": { + const run2 = await runs.retrieve(run); + + type Run2Payload = Expect>; + type Run2Output = Expect>; + + break; + } + } + } + }, +}); + +export const allV2ChildTask1 = task({ + id: "all-v2-test-child-1", + retry: { + maxAttempts: 1, + }, + run: async (payload: { child1: string }) => { + return { + foo: "bar", + }; + }, +}); + +export const allV2ChildTask2 = task({ + id: "all-v2-test-child-2", + retry: { + maxAttempts: 1, + }, + run: async (payload: { child2: string }) => { + return { + bar: "baz", + }; + }, +}); + export const batchV2TestTask = task({ id: "batch-v2-test", retry: { From 4b181fa953de25999c1cfe31012023dffc3ea94e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 27 Nov 2024 13:28:29 +0000 Subject: [PATCH 17/44] Add new batch methods for triggering multiple different tasks in a single batch --- .changeset/perfect-onions-call.md | 80 +++ .../v3/marqs/sharedQueueConsumer.server.ts | 2 + packages/cli-v3/src/dev/backgroundWorker.ts | 1 + .../src/entryPoints/deploy-run-worker.ts | 7 + .../cli-v3/src/entryPoints/dev-run-worker.ts | 6 + packages/core/src/package.json | 3 - packages/core/src/v3/types/tasks.ts | 122 ++++- packages/trigger-sdk/src/package.json | 3 - packages/trigger-sdk/src/v3/batch.ts | 13 + packages/trigger-sdk/src/v3/index.ts | 1 + packages/trigger-sdk/src/v3/shared.ts | 504 ++++++++++++++---- packages/trigger-sdk/src/v3/tasks.ts | 2 - references/v3-catalog/src/trigger/batch.ts | 117 +++- 13 files changed, 703 insertions(+), 158 deletions(-) create mode 100644 .changeset/perfect-onions-call.md delete mode 100644 packages/core/src/package.json delete mode 100644 packages/trigger-sdk/src/package.json create mode 100644 packages/trigger-sdk/src/v3/batch.ts diff --git a/.changeset/perfect-onions-call.md b/.changeset/perfect-onions-call.md new file mode 100644 index 0000000000..b6d982f754 --- /dev/null +++ b/.changeset/perfect-onions-call.md @@ -0,0 +1,80 @@ +--- +"@trigger.dev/sdk": patch +"trigger.dev": patch +"@trigger.dev/core": patch +--- + +Added new batch.trigger and batch.triggerByTask methods that allows triggering multiple different tasks in a single batch: + +```ts +import { batch } from '@trigger.dev/sdk/v3'; +import type { myTask1, myTask2 } from './trigger/tasks'; + +// Somewhere in your backend code +const response = await batch.trigger([ + { id: 'task1', payload: { foo: 'bar' } }, + { id: 'task2', payload: { baz: 'qux' } }, +]); + +for (const run of response.runs) { + if (run.ok) { + console.log(run.output); + } else { + console.error(run.error); + } +} +``` + +Or if you are inside of a task, you can use `triggerByTask`: + +```ts +import { batch, task, runs } from '@trigger.dev/sdk/v3'; + +export const myParentTask = task({ + id: 'myParentTask', + run: async () => { + const response = await batch.triggerByTask([ + { task: myTask1, payload: { foo: 'bar' } }, + { task: myTask2, payload: { baz: 'qux' } }, + ]); + + const run1 = await runs.retrieve(response.runs[0]); + console.log(run1.output) // typed as { foo: string } + + const run2 = await runs.retrieve(response.runs[1]); + console.log(run2.output) // typed as { baz: string } + + const response2 = await batch.triggerByTaskAndWait([ + { task: myTask1, payload: { foo: 'bar' } }, + { task: myTask2, payload: { baz: 'qux' } }, + ]); + + if (response2.runs[0].ok) { + console.log(response2.runs[0].output) // typed as { foo: string } + } + + if (response2.runs[1].ok) { + console.log(response2.runs[1].output) // typed as { baz: string } + } + } +}); + +export const myTask1 = task({ + id: 'myTask1', + run: async () => { + return { + foo: 'bar' + } + } +}); + +export const myTask2 = task({ + id: 'myTask2', + run: async () => { + return { + baz: 'qux' + } + } +}); + +``` diff --git a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts index 041f4fa57c..c45ef65f59 100644 --- a/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts +++ b/apps/webapp/app/v3/marqs/sharedQueueConsumer.server.ts @@ -1038,6 +1038,7 @@ class SharedQueueTasks { id: attempt.taskRun.friendlyId, output: attempt.output ?? undefined, outputType: attempt.outputType, + taskIdentifier: attempt.taskRun.taskIdentifier, }; return success; } else { @@ -1045,6 +1046,7 @@ class SharedQueueTasks { ok, id: attempt.taskRun.friendlyId, error: attempt.error as TaskRunError, + taskIdentifier: attempt.taskRun.taskIdentifier, }; return failure; } diff --git a/packages/cli-v3/src/dev/backgroundWorker.ts b/packages/cli-v3/src/dev/backgroundWorker.ts index 3c85ec4be4..1a617f0824 100644 --- a/packages/cli-v3/src/dev/backgroundWorker.ts +++ b/packages/cli-v3/src/dev/backgroundWorker.ts @@ -498,6 +498,7 @@ export class BackgroundWorker { ok: false, retry: undefined, error: TaskRunProcess.parseExecuteError(e), + taskIdentifier: payload.execution.task.id, }; } } diff --git a/packages/cli-v3/src/entryPoints/deploy-run-worker.ts b/packages/cli-v3/src/entryPoints/deploy-run-worker.ts index ef87e76a05..7b4481c3dc 100644 --- a/packages/cli-v3/src/entryPoints/deploy-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/deploy-run-worker.ts @@ -216,6 +216,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -246,6 +247,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -277,6 +279,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -303,6 +306,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -357,6 +361,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: usageSample.cpuTime, }, + taskIdentifier: execution.task.id, }, }); } @@ -380,6 +385,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: usageSample.cpuTime, }, + taskIdentifier: execution.task.id, }, }); } @@ -402,6 +408,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); } diff --git a/packages/cli-v3/src/entryPoints/dev-run-worker.ts b/packages/cli-v3/src/entryPoints/dev-run-worker.ts index d4a1c05a56..c043cf6712 100644 --- a/packages/cli-v3/src/entryPoints/dev-run-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-run-worker.ts @@ -194,6 +194,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -222,6 +223,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -247,6 +249,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -273,6 +276,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: 0, }, + taskIdentifier: execution.task.id, }, }); @@ -324,6 +328,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: usageSample.cpuTime, }, + taskIdentifier: execution.task.id, }, }); } @@ -347,6 +352,7 @@ const zodIpc = new ZodIpcConnection({ usage: { durationMs: usageSample.cpuTime, }, + taskIdentifier: execution.task.id, }, }); } diff --git a/packages/core/src/package.json b/packages/core/src/package.json deleted file mode 100644 index 3dbc1ca591..0000000000 --- a/packages/core/src/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 486a4df2ed..e075211429 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -49,18 +49,24 @@ export class SubtaskUnwrapError extends Error { } } -export class TaskRunPromise extends Promise> { +export class TaskRunPromise extends Promise< + TaskRunResult +> { constructor( executor: ( - resolve: (value: TaskRunResult | PromiseLike>) => void, + resolve: ( + value: + | TaskRunResult + | PromiseLike> + ) => void, reject: (reason?: any) => void ) => void, - private readonly taskId: string + private readonly taskId: TIdentifier ) { super(executor); } - unwrap(): Promise { + unwrap(): Promise { return this.then((result) => { if (result.ok) { return result.output; @@ -415,25 +421,70 @@ export type RunHandleTaskIdentifier = TRunHandle extends RunHandle< ? TTaskIdentifier : never; -export type TaskRunResult = +export type TaskRunResult = | { ok: true; id: string; - taskIdentifier: string; + taskIdentifier: TIdentifier; output: TOutput; } | { ok: false; id: string; - taskIdentifier: string; + taskIdentifier: TIdentifier; error: unknown; }; -export type BatchResult = { +export type AnyTaskRunResult = TaskRunResult; + +export type TaskRunResultFromTask = TTask extends Task< + infer TIdentifier, + any, + infer TOutput +> + ? TaskRunResult + : never; + +export type BatchResult = { + id: string; + runs: TaskRunResult[]; +}; + +export type BatchByIdResult = { + id: string; + runs: Array>; +}; + +export type BatchByTaskResult = { id: string; - runs: TaskRunResult[]; + runs: { + [K in keyof TTasks]: TaskRunResultFromTask; + }; }; +/** + * A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner. + */ +// export type BatchTasksRunHandle = BrandedRun< +// { +// batchId: string; +// isCached: boolean; +// idempotencyKey?: string; +// runs: { +// [K in keyof TTasks]: BatchedRunHandle< +// TaskIdentifier, +// TaskPayload, +// TaskOutput +// >; +// }; +// publicAccessToken: string; +// }, +// any, +// any +// >; + +export type BatchTasksResult = BatchTasksRunHandle; + export type BatchItem = { payload: TInput; options?: TriggerOptions }; export type BatchTriggerAndWaitItem = { @@ -441,12 +492,30 @@ export type BatchTriggerAndWaitItem = { options?: TriggerAndWaitOptions; }; -export type BatchAllItem = { - task: TRunTypes["taskIdentifier"]; +export type BatchByIdItem = { + id: TRunTypes["taskIdentifier"]; + payload: TRunTypes["payload"]; + options?: TriggerOptions; +}; + +export type BatchByIdAndWaitItem = { + id: TRunTypes["taskIdentifier"]; payload: TRunTypes["payload"]; + options?: TriggerAndWaitOptions; +}; + +export type BatchByTaskItem = { + task: TTask; + payload: TaskPayload; options?: TriggerOptions; }; +export type BatchByTaskAndWaitItem = { + task: TTask; + payload: TaskPayload; + options?: TriggerAndWaitOptions; +}; + export interface Task { /** * The id of the task. @@ -497,7 +566,10 @@ export interface Task * } * ``` */ - triggerAndWait: (payload: TInput, options?: TriggerAndWaitOptions) => TaskRunPromise; + triggerAndWait: ( + payload: TInput, + options?: TriggerAndWaitOptions + ) => TaskRunPromise; /** * Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs. @@ -521,7 +593,7 @@ export interface Task */ batchTriggerAndWait: ( items: Array> - ) => Promise>; + ) => Promise>; } export interface TaskWithSchema< @@ -758,3 +830,27 @@ export type RunHandleFromTypes = RunHandle< export type BatchRunHandleFromTypes = TRunTypes extends AnyRunTypes ? BatchRunHandle : never; + +/** + * A BatchRunHandle can be used to retrieve the runs of a batch trigger in a typesafe manner. + */ +export type BatchTasksRunHandle = BrandedRun< + { + batchId: string; + isCached: boolean; + idempotencyKey?: string; + runs: { + [K in keyof TTasks]: BatchedRunHandle< + TaskIdentifier, + TaskPayload, + TaskOutput + >; + }; + publicAccessToken: string; + }, + any, + any +>; + +export type BatchTasksRunHandleFromTypes = + BatchTasksRunHandle; diff --git a/packages/trigger-sdk/src/package.json b/packages/trigger-sdk/src/package.json deleted file mode 100644 index 3dbc1ca591..0000000000 --- a/packages/trigger-sdk/src/package.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "type": "module" -} diff --git a/packages/trigger-sdk/src/v3/batch.ts b/packages/trigger-sdk/src/v3/batch.ts new file mode 100644 index 0000000000..eb8fa9b61e --- /dev/null +++ b/packages/trigger-sdk/src/v3/batch.ts @@ -0,0 +1,13 @@ +import { + batchTriggerById, + batchTriggerByIdAndWait, + batchTriggerTasks, + batchTriggerAndWaitTasks, +} from "./shared.js"; + +export const batch = { + trigger: batchTriggerById, + triggerAndWait: batchTriggerByIdAndWait, + triggerByTask: batchTriggerTasks, + triggerByTaskAndWait: batchTriggerAndWaitTasks, +}; diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 40060920c9..42580dd336 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -3,6 +3,7 @@ export * from "./config.js"; export { retry, type RetryOptions } from "./retry.js"; export { queue } from "./shared.js"; export * from "./tasks.js"; +export * from "./batch.js"; export * from "./wait.js"; export * from "./waitUntil.js"; export * from "./usage.js"; diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index fd15cb026c..b5904ff527 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -32,11 +32,17 @@ import type { AnyRunHandle, AnyRunTypes, AnyTask, - BatchAllItem, + BatchByIdAndWaitItem, + BatchByTaskAndWaitItem, + BatchByIdItem, + BatchByTaskItem, + BatchByTaskResult, + BatchByIdResult, BatchItem, BatchResult, BatchRunHandle, BatchRunHandleFromTypes, + BatchTasksRunHandleFromTypes, BatchTriggerAndWaitItem, BatchTriggerOptions, InferRunTypes, @@ -57,6 +63,7 @@ import type { TaskOutputHandle, TaskPayload, TaskRunResult, + TaskRunResultFromTask, TaskSchema, TaskWithSchema, TaskWithSchemaOptions, @@ -66,6 +73,7 @@ import type { TriggerAndWaitOptions, TriggerApiRequestOptions, TriggerOptions, + AnyTaskRunResult, } from "@trigger.dev/core/v3"; export type { @@ -152,8 +160,8 @@ export function createTask< triggerAndWait: (payload, options) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); - return new TaskRunPromise((resolve, reject) => { - triggerAndWait_internal( + return new TaskRunPromise((resolve, reject) => { + triggerAndWait_internal( taskMetadata && taskMetadata.exportName ? `${taskMetadata.exportName}.triggerAndWait()` : `triggerAndWait()`, @@ -176,7 +184,7 @@ export function createTask< batchTriggerAndWait: async (items) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); - return await batchTriggerAndWait_internal( + return await batchTriggerAndWait_internal( taskMetadata && taskMetadata.exportName ? `${taskMetadata.exportName}.batchTriggerAndWait()` : `batchTriggerAndWait()`, @@ -297,8 +305,8 @@ export function createSchemaTask< triggerAndWait: (payload, options) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); - return new TaskRunPromise((resolve, reject) => { - triggerAndWait_internal, TOutput>( + return new TaskRunPromise((resolve, reject) => { + triggerAndWait_internal, TOutput>( taskMetadata && taskMetadata.exportName ? `${taskMetadata.exportName}.triggerAndWait()` : `triggerAndWait()`, @@ -321,7 +329,7 @@ export function createSchemaTask< batchTriggerAndWait: async (items) => { const taskMetadata = taskCatalog.getTaskManifest(params.id); - return await batchTriggerAndWait_internal, TOutput>( + return await batchTriggerAndWait_internal, TOutput>( taskMetadata && taskMetadata.exportName ? `${taskMetadata.exportName}.batchTriggerAndWait()` : `batchTriggerAndWait()`, @@ -415,9 +423,9 @@ export function triggerAndWait( payload: TaskPayload, options?: TriggerAndWaitOptions, requestOptions?: ApiRequestOptions -): TaskRunPromise> { - return new TaskRunPromise>((resolve, reject) => { - triggerAndWait_internal, TaskOutput>( +): TaskRunPromise, TaskOutput> { + return new TaskRunPromise, TaskOutput>((resolve, reject) => { + triggerAndWait_internal, TaskPayload, TaskOutput>( "tasks.triggerAndWait()", id, payload, @@ -462,14 +470,12 @@ export async function batchTriggerAndWait( id: TaskIdentifier, items: Array>>, requestOptions?: ApiRequestOptions -): Promise>> { - return await batchTriggerAndWait_internal, TaskOutput>( - "tasks.batchTriggerAndWait()", - id, - items, - undefined, - requestOptions - ); +): Promise, TaskOutput>> { + return await batchTriggerAndWait_internal< + TaskIdentifier, + TaskPayload, + TaskOutput + >("tasks.batchTriggerAndWait()", id, items, undefined, requestOptions); } /** @@ -514,16 +520,327 @@ export async function batchTrigger( ); } -export async function triggerAll( - items: Array>>, +export async function batchTriggerById( + items: Array>>, options?: BatchTriggerOptions, requestOptions?: TriggerApiRequestOptions ): Promise>> { - return await triggerAll_internal>( - "tasks.triggerAll()", - items, - options, - requestOptions + const apiClient = apiClientManager.clientOrThrow(); + + const response = await apiClient.batchTriggerV2( + { + items: await Promise.all( + items.map(async (item) => { + const taskMetadata = taskCatalog.getTask(item.id); + + const parsedPayload = taskMetadata?.fns.parsePayload + ? await taskMetadata?.fns.parsePayload(item.payload) + : item.payload; + + const payloadPacket = await stringifyIO(parsedPayload); + + return { + task: item.id, + payload: payloadPacket.data, + options: { + queue: item.options?.queue, + concurrencyKey: item.options?.concurrencyKey, + test: taskContext.ctx?.run.isTest, + payloadType: payloadPacket.dataType, + idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey), + idempotencyKeyTTL: item.options?.idempotencyKeyTTL, + delay: item.options?.delay, + ttl: item.options?.ttl, + tags: item.options?.tags, + maxAttempts: item.options?.maxAttempts, + parentAttempt: taskContext.ctx?.attempt.id, + metadata: item.options?.metadata, + maxDuration: item.options?.maxDuration, + }, + }; + }) + ), + }, + { + spanParentAsLink: true, + idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKeyTTL: options?.idempotencyKeyTTL, + }, + { + name: "batch.trigger()", + tracer, + icon: "trigger", + onResponseBody(body, span) { + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "id" in body && + typeof body.id === "string" + ) { + span.setAttribute("batchId", body.id); + } + + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "runs" in body && + Array.isArray(body.runs) + ) { + span.setAttribute("runCount", body.runs.length); + } + }, + ...requestOptions, + } + ); + + const handle = { + batchId: response.id, + isCached: response.isCached, + idempotencyKey: response.idempotencyKey, + runs: response.runs, + publicAccessToken: response.publicAccessToken, + }; + + return handle as BatchRunHandleFromTypes>; +} + +export async function batchTriggerByIdAndWait( + items: Array>>, + requestOptions?: TriggerApiRequestOptions +): Promise> { + const ctx = taskContext.ctx; + + if (!ctx) { + throw new Error("batchTriggerAndWait can only be used from inside a task.run()"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + return await tracer.startActiveSpan( + "batch.triggerAndWait()", + async (span) => { + const response = await apiClient.batchTriggerV2( + { + items: await Promise.all( + items.map(async (item) => { + const taskMetadata = taskCatalog.getTask(item.id); + + const parsedPayload = taskMetadata?.fns.parsePayload + ? await taskMetadata?.fns.parsePayload(item.payload) + : item.payload; + + const payloadPacket = await stringifyIO(parsedPayload); + + return { + task: item.id, + payload: payloadPacket.data, + options: { + lockToVersion: taskContext.worker?.version, + queue: item.options?.queue, + concurrencyKey: item.options?.concurrencyKey, + test: taskContext.ctx?.run.isTest, + payloadType: payloadPacket.dataType, + delay: item.options?.delay, + ttl: item.options?.ttl, + tags: item.options?.tags, + maxAttempts: item.options?.maxAttempts, + metadata: item.options?.metadata, + maxDuration: item.options?.maxDuration, + }, + }; + }) + ), + dependentAttempt: ctx.attempt.id, + }, + {}, + requestOptions + ); + + span.setAttribute("batchId", response.id); + span.setAttribute("runCount", response.runs.length); + + const result = await runtime.waitForBatch({ + id: response.id, + runs: response.runs.map((run) => run.id), + ctx, + }); + + const runs = await handleBatchTaskRunExecutionResultV2(result.items); + + return { + id: result.id, + runs, + } as BatchByIdResult; + }, + { + kind: SpanKind.PRODUCER, + } + ); +} + +export async function batchTriggerTasks( + items: { + [K in keyof TTasks]: BatchByTaskItem; + }, + options?: BatchTriggerOptions, + requestOptions?: TriggerApiRequestOptions +): Promise> { + const apiClient = apiClientManager.clientOrThrow(); + + const response = await apiClient.batchTriggerV2( + { + items: await Promise.all( + items.map(async (item) => { + const taskMetadata = taskCatalog.getTask(item.task.id); + + const parsedPayload = taskMetadata?.fns.parsePayload + ? await taskMetadata?.fns.parsePayload(item.payload) + : item.payload; + + const payloadPacket = await stringifyIO(parsedPayload); + + return { + task: item.task.id, + payload: payloadPacket.data, + options: { + queue: item.options?.queue, + concurrencyKey: item.options?.concurrencyKey, + test: taskContext.ctx?.run.isTest, + payloadType: payloadPacket.dataType, + idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey), + idempotencyKeyTTL: item.options?.idempotencyKeyTTL, + delay: item.options?.delay, + ttl: item.options?.ttl, + tags: item.options?.tags, + maxAttempts: item.options?.maxAttempts, + parentAttempt: taskContext.ctx?.attempt.id, + metadata: item.options?.metadata, + maxDuration: item.options?.maxDuration, + }, + }; + }) + ), + }, + { + spanParentAsLink: true, + idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), + idempotencyKeyTTL: options?.idempotencyKeyTTL, + }, + { + name: "batch.triggerByTask()", + tracer, + icon: "trigger", + onResponseBody(body, span) { + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "id" in body && + typeof body.id === "string" + ) { + span.setAttribute("batchId", body.id); + } + + if ( + body && + typeof body === "object" && + !Array.isArray(body) && + "runs" in body && + Array.isArray(body.runs) + ) { + span.setAttribute("runCount", body.runs.length); + } + }, + ...requestOptions, + } + ); + + const handle = { + batchId: response.id, + isCached: response.isCached, + idempotencyKey: response.idempotencyKey, + runs: response.runs, + publicAccessToken: response.publicAccessToken, + }; + + return handle as unknown as BatchTasksRunHandleFromTypes; +} + +export async function batchTriggerAndWaitTasks( + items: { + [K in keyof TTasks]: BatchByTaskAndWaitItem; + }, + requestOptions?: TriggerApiRequestOptions +): Promise> { + const ctx = taskContext.ctx; + + if (!ctx) { + throw new Error("batchTriggerAndWait can only be used from inside a task.run()"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + return await tracer.startActiveSpan( + "batch.triggerByTaskAndWait()", + async (span) => { + const response = await apiClient.batchTriggerV2( + { + items: await Promise.all( + items.map(async (item) => { + const taskMetadata = taskCatalog.getTask(item.task.id); + + const parsedPayload = taskMetadata?.fns.parsePayload + ? await taskMetadata?.fns.parsePayload(item.payload) + : item.payload; + + const payloadPacket = await stringifyIO(parsedPayload); + + return { + task: item.task.id, + payload: payloadPacket.data, + options: { + lockToVersion: taskContext.worker?.version, + queue: item.options?.queue, + concurrencyKey: item.options?.concurrencyKey, + test: taskContext.ctx?.run.isTest, + payloadType: payloadPacket.dataType, + delay: item.options?.delay, + ttl: item.options?.ttl, + tags: item.options?.tags, + maxAttempts: item.options?.maxAttempts, + metadata: item.options?.metadata, + maxDuration: item.options?.maxDuration, + }, + }; + }) + ), + dependentAttempt: ctx.attempt.id, + }, + {}, + requestOptions + ); + + span.setAttribute("batchId", response.id); + span.setAttribute("runCount", response.runs.length); + + const result = await runtime.waitForBatch({ + id: response.id, + runs: response.runs.map((run) => run.id), + ctx, + }); + + const runs = await handleBatchTaskRunExecutionResultV2(result.items); + + return { + id: result.id, + runs, + } as BatchByTaskResult; + }, + { + kind: SpanKind.PRODUCER, + } ); } @@ -669,14 +986,14 @@ async function batchTrigger_internal( return handle as BatchRunHandleFromTypes; } -async function triggerAndWait_internal( +async function triggerAndWait_internal( name: string, - id: string, + id: TIdentifier, payload: TPayload, parsePayload?: SchemaParseFn, options?: TriggerAndWaitOptions, requestOptions?: ApiRequestOptions -): Promise> { +): Promise> { const ctx = taskContext.ctx; if (!ctx) { @@ -722,7 +1039,7 @@ async function triggerAndWait_internal( ctx, }); - return await handleTaskRunExecutionResult(result, id); + return await handleTaskRunExecutionResult(result, id); }, { kind: SpanKind.PRODUCER, @@ -741,14 +1058,14 @@ async function triggerAndWait_internal( ); } -async function batchTriggerAndWait_internal( +async function batchTriggerAndWait_internal( name: string, - id: string, + id: TIdentifier, items: Array>, parsePayload?: SchemaParseFn, requestOptions?: ApiRequestOptions, queue?: QueueOptions -): Promise> { +): Promise> { const ctx = taskContext.ctx; if (!ctx) { @@ -802,7 +1119,7 @@ async function batchTriggerAndWait_internal( ctx, }); - const runs = await handleBatchTaskRunExecutionResult(result.items, id); + const runs = await handleBatchTaskRunExecutionResult(result.items, id); return { id: result.id, @@ -826,92 +1143,45 @@ async function batchTriggerAndWait_internal( ); } -async function triggerAll_internal( - name: string, - items: Array>, - options?: BatchTriggerOptions, - requestOptions?: TriggerApiRequestOptions, - queue?: QueueOptions -): Promise> { - const apiClient = apiClientManager.clientOrThrow(); +async function handleBatchTaskRunExecutionResult( + items: Array, + taskIdentifier: TIdentifier +): Promise>> { + const someObjectStoreOutputs = items.some( + (item) => item.ok && item.outputType === "application/store" + ); - const response = await apiClient.batchTriggerV2( - { - items: await Promise.all( - items.map(async (item) => { - const payloadPacket = await stringifyIO(item.payload); + if (!someObjectStoreOutputs) { + const results = await Promise.all( + items.map(async (item) => { + return await handleTaskRunExecutionResult(item, taskIdentifier); + }) + ); - return { - task: item.task, - payload: payloadPacket.data, - options: { - queue: item.options?.queue ?? queue, - concurrencyKey: item.options?.concurrencyKey, - test: taskContext.ctx?.run.isTest, - payloadType: payloadPacket.dataType, - idempotencyKey: await makeIdempotencyKey(item.options?.idempotencyKey), - idempotencyKeyTTL: item.options?.idempotencyKeyTTL, - delay: item.options?.delay, - ttl: item.options?.ttl, - tags: item.options?.tags, - maxAttempts: item.options?.maxAttempts, - parentAttempt: taskContext.ctx?.attempt.id, - metadata: item.options?.metadata, - maxDuration: item.options?.maxDuration, - }, - }; + return results; + } + + return await tracer.startActiveSpan( + "store.downloadPayloads", + async (span) => { + const results = await Promise.all( + items.map(async (item) => { + return await handleTaskRunExecutionResult(item, taskIdentifier); }) - ), - }, - { - spanParentAsLink: true, - idempotencyKey: await makeIdempotencyKey(options?.idempotencyKey), - idempotencyKeyTTL: options?.idempotencyKeyTTL, + ); + + return results; }, { - name, - tracer, - icon: "trigger", - onResponseBody(body, span) { - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "id" in body && - typeof body.id === "string" - ) { - span.setAttribute("batchId", body.id); - } - - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "runs" in body && - Array.isArray(body.runs) - ) { - span.setAttribute("runCount", body.runs.length); - } - }, - ...requestOptions, + kind: SpanKind.INTERNAL, + [SemanticInternalAttributes.STYLE_ICON]: "cloud-download", } ); - - const handle = { - batchId: response.id, - isCached: response.isCached, - idempotencyKey: response.idempotencyKey, - runs: response.runs, - publicAccessToken: response.publicAccessToken, - }; - - return handle as BatchRunHandleFromTypes; } -async function handleBatchTaskRunExecutionResult( - items: Array, - taskIdentifier: string -): Promise>> { +async function handleBatchTaskRunExecutionResultV2( + items: Array +): Promise> { const someObjectStoreOutputs = items.some( (item) => item.ok && item.outputType === "application/store" ); @@ -919,7 +1189,7 @@ async function handleBatchTaskRunExecutionResult( if (!someObjectStoreOutputs) { const results = await Promise.all( items.map(async (item) => { - return await handleTaskRunExecutionResult(item, taskIdentifier); + return await handleTaskRunExecutionResult(item, item.taskIdentifier ?? "unknown"); }) ); @@ -931,7 +1201,7 @@ async function handleBatchTaskRunExecutionResult( async (span) => { const results = await Promise.all( items.map(async (item) => { - return await handleTaskRunExecutionResult(item, taskIdentifier); + return await handleTaskRunExecutionResult(item, item.taskIdentifier ?? "unknown"); }) ); @@ -944,10 +1214,10 @@ async function handleBatchTaskRunExecutionResult( ); } -async function handleTaskRunExecutionResult( +async function handleTaskRunExecutionResult( execution: TaskRunExecutionResult, - taskIdentifier: string -): Promise> { + taskIdentifier: TIdentifier +): Promise> { if (execution.ok) { const outputPacket = { data: execution.output, dataType: execution.outputType }; const importedPacket = await conditionallyImportPacket(outputPacket, tracer); @@ -955,14 +1225,14 @@ async function handleTaskRunExecutionResult( return { ok: true, id: execution.id, - taskIdentifier: execution.taskIdentifier ?? taskIdentifier, + taskIdentifier: (execution.taskIdentifier ?? taskIdentifier) as TIdentifier, output: await parsePacket(importedPacket), }; } else { return { ok: false, id: execution.id, - taskIdentifier: execution.taskIdentifier ?? taskIdentifier, + taskIdentifier: (execution.taskIdentifier ?? taskIdentifier) as TIdentifier, error: createErrorTaskError(execution.error), }; } diff --git a/packages/trigger-sdk/src/v3/tasks.ts b/packages/trigger-sdk/src/v3/tasks.ts index bb65417dd7..c38f77187a 100644 --- a/packages/trigger-sdk/src/v3/tasks.ts +++ b/packages/trigger-sdk/src/v3/tasks.ts @@ -8,7 +8,6 @@ import { trigger, triggerAndPoll, triggerAndWait, - triggerAll, } from "./shared.js"; export { SubtaskUnwrapError }; @@ -77,5 +76,4 @@ export const tasks = { batchTrigger, triggerAndWait, batchTriggerAndWait, - triggerAll, }; diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index 56368ed640..b7a12b3415 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -1,14 +1,4 @@ -import { - AnyRealtimeRun, - auth, - logger, - RealtimeRun, - runs, - task, - TaskFromIdentifier, - tasks, - wait, -} from "@trigger.dev/sdk/v3"; +import { AnyRealtimeRun, auth, batch, logger, runs, task, tasks, wait } from "@trigger.dev/sdk/v3"; import assert from "node:assert"; import { randomUUID } from "node:crypto"; import { setTimeout } from "node:timers/promises"; @@ -156,17 +146,13 @@ export const allV2TestTask = task({ maxAttempts: 1, }, run: async () => { - const response1 = await tasks.triggerAll([ - { task: "all-v2-test-child-1", payload: { child1: "foo" } }, - { task: "all-v2-test-child-2", payload: { child2: "bar" } }, - { task: "all-v2-test-child-1", payload: { child1: "baz" } }, + const response1 = await batch.trigger([ + { id: "all-v2-test-child-1", payload: { child1: "foo" } }, + { id: "all-v2-test-child-2", payload: { child2: "bar" } }, + { id: "all-v2-test-child-1", payload: { child1: "baz" } }, ]); - // This would have the type of the first task above - const firstRunHandle = response1.runs[0]; - const run1 = await runs.retrieve(firstRunHandle); - - type Run1Payload = Expect>; + logger.debug("Response 1", { response1 }); for (const run of response1.runs) { switch (run.taskIdentifier) { @@ -188,6 +174,97 @@ export const allV2TestTask = task({ } } } + + const { + runs: [batchRun1, batchRun2, batchRun3], + } = await batch.triggerByTask([ + { task: allV2ChildTask1, payload: { child1: "foo" } }, + { task: allV2ChildTask2, payload: { child2: "bar" } }, + { task: allV2ChildTask1, payload: { child1: "baz" } }, + ]); + + logger.debug("Batch runs", { batchRun1, batchRun2, batchRun3 }); + + const taskRun1 = await runs.retrieve(batchRun1); + + type TaskRun1Payload = Expect>; + type TaskRun1Output = Expect>; + + const taskRun2 = await runs.retrieve(batchRun2); + + type TaskRun2Payload = Expect>; + type TaskRun2Output = Expect>; + + const taskRun3 = await runs.retrieve(batchRun3); + + type TaskRun3Payload = Expect>; + type TaskRun3Output = Expect>; + + const response3 = await batch.triggerAndWait([ + { id: "all-v2-test-child-1", payload: { child1: "foo" } }, + { id: "all-v2-test-child-2", payload: { child2: "bar" } }, + { id: "all-v2-test-child-1", payload: { child1: "baz" } }, + ]); + + logger.debug("Response 3", { response3 }); + + for (const run of response3.runs) { + if (run.ok) { + switch (run.taskIdentifier) { + case "all-v2-test-child-1": { + type Run1Output = Expect>; + + break; + } + case "all-v2-test-child-2": { + type Run2Output = Expect>; + + break; + } + } + } + } + + for (const run of response3.runs) { + switch (run.taskIdentifier) { + case "all-v2-test-child-1": { + if (run.ok) { + type Run1Output = Expect>; + } + + break; + } + case "all-v2-test-child-2": { + if (run.ok) { + type Run2Output = Expect>; + } + + break; + } + } + } + + const { + runs: [batch2Run1, batch2Run2, batch2Run3], + } = await batch.triggerByTaskAndWait([ + { task: allV2ChildTask1, payload: { child1: "foo" } }, + { task: allV2ChildTask2, payload: { child2: "bar" } }, + { task: allV2ChildTask1, payload: { child1: "baz" } }, + ]); + + logger.debug("Batch 2 runs", { batch2Run1, batch2Run2, batch2Run3 }); + + if (batch2Run1.ok) { + type Batch2Run1Output = Expect>; + } + + if (batch2Run2.ok) { + type Batch2Run2Output = Expect>; + } + + if (batch2Run3.ok) { + type Batch2Run3Output = Expect>; + } }, }); From fd187845a58bb5f22a54213b2fe1e48297825091 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 26 Nov 2024 16:05:16 +0000 Subject: [PATCH 18/44] Disabled switch styling --- apps/webapp/app/components/primitives/Switch.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/primitives/Switch.tsx b/apps/webapp/app/components/primitives/Switch.tsx index 342678c9be..26d355c76b 100644 --- a/apps/webapp/app/components/primitives/Switch.tsx +++ b/apps/webapp/app/components/primitives/Switch.tsx @@ -13,10 +13,10 @@ const variations = { }, small: { container: - "flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 transition focus-custom", + "flex items-center h-[1.5rem] gap-x-1.5 rounded hover:bg-tertiary disabled:hover:bg-transparent pr-1 py-[0.1rem] pl-1.5 transition focus-custom disabled:hover:text-charcoal-400 disabled:opacity-50 text-charcoal-400 hover:text-charcoal-200 disabled:hover:cursor-not-allowed hover:cursor-pointer", root: "h-3 w-6", thumb: "h-2.5 w-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0", - text: "text-xs text-charcoal-400 group-hover:text-charcoal-200 hover:cursor-pointer transition", + text: "text-xs transition", }, }; From 26980f06f723d687857f97f2fb5729d50b0bd700 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 26 Nov 2024 16:05:58 +0000 Subject: [PATCH 19/44] Batch filtering, force child runs to show if filtering by batch/run --- .../app/components/runs/v3/RunFilters.tsx | 132 +++++++++++++++++- .../presenters/v3/RunListPresenter.server.ts | 21 ++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index fd4a91036f..dce94ba7fb 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -5,6 +5,7 @@ import { CpuChipIcon, FingerPrintIcon, InboxStackIcon, + Squares2X2Icon, TagIcon, TrashIcon, XMarkIcon, @@ -169,6 +170,7 @@ const filterTypes = [ { name: "bulk", title: "Bulk action", icon: }, { name: "daterange", title: "Custom date range", icon: }, { name: "run", title: "Run id", icon: }, + { name: "batch", title: "Batch id", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -241,6 +243,7 @@ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: Ru return ( <> + @@ -280,6 +283,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "run": return props.setFilterType(undefined)} {...props} />; + case "batch": + return props.setFilterType(undefined)} {...props} />; } } @@ -1125,11 +1130,17 @@ function ShowChildTasksToggle() { const showChildTasks = value("showChildTasks") === "true"; + const batchId = value("batchId"); + const runId = value("runId"); + + const disabled = !!batchId || !!runId; + return ( { replace({ showChildTasks: checked ? "true" : undefined, @@ -1200,6 +1211,7 @@ function RunIdDropdown({ onChange={(e) => setRunId(e.target.value)} variant="small" className="w-[27ch] font-mono" + spellCheck={false} /> {error ? {error} : null} @@ -1256,6 +1268,124 @@ function AppliedRunIdFilter() { ); } +function BatchIdDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const batchIdValue = value("batchId"); + + const [batchId, setBatchId] = useState(batchIdValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + batchId: batchId === "" ? undefined : batchId?.toString(), + }); + + setOpen(false); + }, [batchId, replace]); + + let error: string | undefined = undefined; + if (batchId) { + if (!batchId.startsWith("batch_")) { + error = "Batch IDs start with 'batch_'"; + } else if (batchId.length !== 27) { + error = "Batch IDs are 27 characters long"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setBatchId(e.target.value)} + variant="small" + className="w-[29ch] font-mono" + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedBatchIdFilter() { + const { value, del } = useSearchParams(); + + if (value("batchId") === undefined) { + return null; + } + + const batchId = value("batchId"); + + return ( + + {(search, setSearch) => ( + }> + del(["batchId", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + function dateFromString(value: string | undefined | null): Date | undefined { if (!value) return; diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index f5c0ab42b4..d0f060aa34 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -150,6 +150,25 @@ export class RunListPresenter extends BasePresenter { } } + //batch id is a friendly id + if (batchId) { + const batch = await this._replica.batchTaskRun.findFirst({ + select: { + id: true, + }, + where: { + friendlyId: batchId, + }, + }); + + batchId = batch?.id; + } + + //show all runs if we are filtering by batchId or runId + if (batchId || runId) { + rootOnly = false; + } + const periodMs = period ? parse(period) : undefined; //get the runs @@ -222,6 +241,7 @@ WHERE } -- filters ${runId ? Prisma.sql`AND tr."friendlyId" = ${runId}` : Prisma.empty} + ${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty} ${ restrictToRunIds ? restrictToRunIds.length === 0 @@ -265,7 +285,6 @@ WHERE : Prisma.empty } ${rootOnly === true ? Prisma.sql`AND tr."rootTaskRunId" IS NULL` : Prisma.empty} - ${batchId ? Prisma.sql`AND tr."batchId" = ${batchId}` : Prisma.empty} GROUP BY tr.id, bw.version ORDER BY From 492fb7901f2553304c3401e036f3c0b858a058ca Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 26 Nov 2024 16:30:03 +0000 Subject: [PATCH 20/44] Added schedule ID filtering --- .../app/components/runs/v3/RunFilters.tsx | 167 +++++++++++++++--- .../presenters/v3/RunListPresenter.server.ts | 22 ++- .../route.tsx | 3 + 3 files changed, 167 insertions(+), 25 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index dce94ba7fb..afeb7dda75 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1,29 +1,34 @@ import * as Ariakit from "@ariakit/react"; import { - ArrowPathIcon, CalendarIcon, + ClockIcon, CpuChipIcon, FingerPrintIcon, - InboxStackIcon, Squares2X2Icon, TagIcon, TrashIcon, - XMarkIcon, } from "@heroicons/react/20/solid"; +import { ListChecks } from "lucide-react"; import { Form, useFetcher } from "@remix-run/react"; import type { + BulkActionType, RuntimeEnvironment, - TaskTriggerSource, TaskRunStatus, - BulkActionType, + TaskTriggerSource, } from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-react"; +import { matchSorter } from "match-sorter"; import type { ReactNode } from "react"; import { startTransition, useCallback, useEffect, useMemo, useState } from "react"; import { z } from "zod"; import { TaskIcon } from "~/assets/icons/TaskIcon"; import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { DateField } from "~/components/primitives/DateField"; +import { DateTime } from "~/components/primitives/DateTime"; +import { FormError } from "~/components/primitives/FormError"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { ComboBox, @@ -36,6 +41,8 @@ import { SelectTrigger, shortcutFromIndex, } from "~/components/primitives/Select"; +import { Spinner } from "~/components/primitives/Spinner"; +import { Switch } from "~/components/primitives/Switch"; import { Tooltip, TooltipContent, @@ -43,28 +50,19 @@ import { TooltipTrigger, } from "~/components/primitives/Tooltip"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; +import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; import { Button } from "../../primitives/Buttons"; +import { BulkActionStatusCombo } from "./BulkAction"; import { TaskRunStatusCombo, allTaskRunStatuses, - filterableTaskRunStatuses, descriptionForTaskRunStatus, + filterableTaskRunStatuses, runStatusTitle, } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; -import { DateTime } from "~/components/primitives/DateTime"; -import { BulkActionStatusCombo } from "./BulkAction"; -import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; -import { useProject } from "~/hooks/useProject"; -import { Spinner } from "~/components/primitives/Spinner"; -import { matchSorter } from "match-sorter"; -import { DateField } from "~/components/primitives/DateField"; -import { Label } from "~/components/primitives/Label"; -import { Switch } from "~/components/primitives/Switch"; -import { Input } from "~/components/primitives/Input"; -import { Hint } from "~/components/primitives/Hint"; -import { FormError } from "~/components/primitives/FormError"; export const TaskAttemptStatus = z.enum(allTaskRunStatuses); @@ -98,6 +96,7 @@ export const TaskRunListSearchFilters = z.object({ showChildTasks: z.coerce.boolean().optional(), batchId: z.string().optional(), runId: z.string().optional(), + scheduleId: z.string().optional(), }); export type TaskRunListSearchFilters = z.infer; @@ -167,10 +166,11 @@ const filterTypes = [ { name: "tasks", title: "Tasks", icon: }, { name: "tags", title: "Tags", icon: }, { name: "created", title: "Created", icon: }, - { name: "bulk", title: "Bulk action", icon: }, { name: "daterange", title: "Custom date range", icon: }, - { name: "run", title: "Run id", icon: }, - { name: "batch", title: "Batch id", icon: }, + { name: "run", title: "Run ID", icon: }, + { name: "batch", title: "Batch ID", icon: }, + { name: "schedule", title: "Schedule ID", icon: }, + { name: "bulk", title: "Bulk action", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -242,14 +242,15 @@ function FilterMenuProvider({ function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) { return ( <> - - + + + ); @@ -285,6 +286,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "batch": return props.setFilterType(undefined)} {...props} />; + case "schedule": + return props.setFilterType(undefined)} {...props} />; } } @@ -1386,6 +1389,124 @@ function AppliedBatchIdFilter() { ); } +function ScheduleIdDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const scheduleIdValue = value("scheduleId"); + + const [scheduleId, setScheduleId] = useState(scheduleIdValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + scheduleId: scheduleId === "" ? undefined : scheduleId?.toString(), + }); + + setOpen(false); + }, [scheduleId, replace]); + + let error: string | undefined = undefined; + if (scheduleId) { + if (!scheduleId.startsWith("sched")) { + error = "Schedule IDs start with 'sched_'"; + } else if (scheduleId.length !== 27) { + error = "Schedule IDs are 27 characters long"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setScheduleId(e.target.value)} + variant="small" + className="w-[29ch] font-mono" + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedScheduleIdFilter() { + const { value, del } = useSearchParams(); + + if (value("scheduleId") === undefined) { + return null; + } + + const scheduleId = value("scheduleId"); + + return ( + + {(search, setSearch) => ( + }> + del(["scheduleId", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + function dateFromString(value: string | undefined | null): Date | undefined { if (!value) return; diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index d0f060aa34..899f594b4d 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -161,11 +161,29 @@ export class RunListPresenter extends BasePresenter { }, }); - batchId = batch?.id; + if (batch) { + batchId = batch?.id; + } + } + + //scheduleId can be a friendlyId + if (scheduleId && scheduleId.startsWith("sched_")) { + const schedule = await this._replica.taskSchedule.findFirst({ + select: { + id: true, + }, + where: { + friendlyId: scheduleId, + }, + }); + + if (schedule) { + scheduleId = schedule?.id; + } } //show all runs if we are filtering by batchId or runId - if (batchId || runId) { + if (batchId || runId || scheduleId) { rootOnly = false; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx index b736a1dda7..1904084526 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.runs._index/route.tsx @@ -68,6 +68,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { showChildTasks: url.searchParams.get("showChildTasks") === "true", runId: url.searchParams.get("runId") ?? undefined, batchId: url.searchParams.get("batchId") ?? undefined, + scheduleId: url.searchParams.get("scheduleId") ?? undefined, }; const { tasks, @@ -84,6 +85,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { showChildTasks, runId, batchId, + scheduleId, } = TaskRunListSearchFilters.parse(s); const project = await findProjectBySlug(organizationSlug, projectParam, userId); @@ -107,6 +109,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { to, batchId, runId, + scheduleId, rootOnly: !showChildTasks, direction: direction, cursor: cursor, From 0060a6496e69b1ac394280885493c4db46954b87 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 26 Nov 2024 16:44:03 +0000 Subject: [PATCH 21/44] Force child runs to show when filtering by scheduleId, for consistency --- apps/webapp/app/components/runs/v3/RunFilters.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index afeb7dda75..ea8814acce 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1135,8 +1135,9 @@ function ShowChildTasksToggle() { const batchId = value("batchId"); const runId = value("runId"); + const scheduleId = value("scheduleId"); - const disabled = !!batchId || !!runId; + const disabled = !!batchId || !!runId || !!scheduleId; return ( Date: Wed, 27 Nov 2024 13:45:01 +0000 Subject: [PATCH 22/44] realtime: allow setting enabled: false on useApiClient --- .../react-hooks/src/hooks/useApiClient.ts | 13 ++++++++- packages/react-hooks/src/hooks/useRealtime.ts | 12 ++++++-- packages/react-hooks/src/hooks/useRun.ts | 28 +++++++++++++------ .../react-hooks/src/hooks/useTaskTrigger.ts | 4 +++ .../src/app/runs/[id]/ClientRunDetails.tsx | 15 +++++++++- 5 files changed, 59 insertions(+), 13 deletions(-) diff --git a/packages/react-hooks/src/hooks/useApiClient.ts b/packages/react-hooks/src/hooks/useApiClient.ts index 21573521b0..b2cb9c6082 100644 --- a/packages/react-hooks/src/hooks/useApiClient.ts +++ b/packages/react-hooks/src/hooks/useApiClient.ts @@ -13,6 +13,13 @@ export type UseApiClientOptions = { baseURL?: string; /** Optional additional request configuration */ requestOptions?: ApiRequestOptions; + + /** + * Enable or disable the API client instance. + * + * Set enabled to false if you don't have an accessToken and don't want to throw an error. + */ + enabled?: boolean; }; /** @@ -35,13 +42,17 @@ export type UseApiClientOptions = { * }); * ``` */ -export function useApiClient(options?: UseApiClientOptions): ApiClient { +export function useApiClient(options?: UseApiClientOptions): ApiClient | undefined { const auth = useTriggerAuthContextOptional(); const baseUrl = options?.baseURL ?? auth?.baseURL ?? "https://api.trigger.dev"; const accessToken = options?.accessToken ?? auth?.accessToken; if (!accessToken) { + if (options?.enabled === false) { + return undefined; + } + throw new Error("Missing accessToken in TriggerAuthContext or useApiClient options"); } diff --git a/packages/react-hooks/src/hooks/useRealtime.ts b/packages/react-hooks/src/hooks/useRealtime.ts index cc9283f515..dc4d982ad0 100644 --- a/packages/react-hooks/src/hooks/useRealtime.ts +++ b/packages/react-hooks/src/hooks/useRealtime.ts @@ -73,7 +73,7 @@ export function useRealtimeRun( const triggerRequest = useCallback(async () => { try { - if (!runId) { + if (!runId || !apiClient) { return; } @@ -207,7 +207,7 @@ export function useRealtimeRunWithStreams< const triggerRequest = useCallback(async () => { try { - if (!runId) { + if (!runId || !apiClient) { return; } @@ -321,6 +321,10 @@ export function useRealtimeRunsWithTag( const triggerRequest = useCallback(async () => { try { + if (!apiClient) { + return; + } + const abortController = new AbortController(); abortControllerRef.current = abortController; @@ -407,6 +411,10 @@ export function useRealtimeBatch( const triggerRequest = useCallback(async () => { try { + if (!apiClient) { + return; + } + const abortController = new AbortController(); abortControllerRef.current = abortController; diff --git a/packages/react-hooks/src/hooks/useRun.ts b/packages/react-hooks/src/hooks/useRun.ts index b102e7834c..9c248c7167 100644 --- a/packages/react-hooks/src/hooks/useRun.ts +++ b/packages/react-hooks/src/hooks/useRun.ts @@ -33,17 +33,27 @@ export function useRun( error, isLoading, isValidating, - } = useSWR>(runId, () => apiClient.retrieveRun(runId), { - revalidateOnReconnect: options?.revalidateOnReconnect, - refreshInterval: (run) => { - if (!run) return options?.refreshInterval ?? 0; + } = useSWR>( + runId, + () => { + if (!apiClient) { + throw new Error("Could not call useRun: Missing access token"); + } - if (run.isCompleted) return 0; - - return options?.refreshInterval ?? 0; + return apiClient.retrieveRun(runId); }, - revalidateOnFocus: options?.revalidateOnFocus, - }); + { + revalidateOnReconnect: options?.revalidateOnReconnect, + refreshInterval: (run) => { + if (!run) return options?.refreshInterval ?? 0; + + if (run.isCompleted) return 0; + + return options?.refreshInterval ?? 0; + }, + revalidateOnFocus: options?.revalidateOnFocus, + } + ); return { run, error, isLoading, isValidating, isError: !!error }; } diff --git a/packages/react-hooks/src/hooks/useTaskTrigger.ts b/packages/react-hooks/src/hooks/useTaskTrigger.ts index e2c41ad172..ba305f2d48 100644 --- a/packages/react-hooks/src/hooks/useTaskTrigger.ts +++ b/packages/react-hooks/src/hooks/useTaskTrigger.ts @@ -66,6 +66,10 @@ export function useTaskTrigger( arg: { payload, options }, }: { arg: { payload: TaskPayload; options?: TriggerOptions } } ) { + if (!apiClient) { + throw new Error("Could not trigger task in useTaskTrigger: Missing access token"); + } + const payloadPacket = await stringifyIO(payload); const handle = await apiClient.triggerTask(id, { diff --git a/references/nextjs-realtime/src/app/runs/[id]/ClientRunDetails.tsx b/references/nextjs-realtime/src/app/runs/[id]/ClientRunDetails.tsx index 280048578f..b4c1094fe7 100644 --- a/references/nextjs-realtime/src/app/runs/[id]/ClientRunDetails.tsx +++ b/references/nextjs-realtime/src/app/runs/[id]/ClientRunDetails.tsx @@ -4,6 +4,7 @@ import RunDetails from "@/components/RunDetails"; import { Card, CardContent } from "@/components/ui/card"; import { TriggerAuthContext, useRealtimeRun } from "@trigger.dev/react-hooks"; import type { exampleTask } from "@/trigger/example"; +import { useEffect, useState } from "react"; function RunDetailsWrapper({ runId, @@ -12,8 +13,20 @@ function RunDetailsWrapper({ runId: string; publicAccessToken: string; }) { + const [accessToken, setAccessToken] = useState(undefined); + + // call setAccessToken with publicAccessToken after 2 seconds + useEffect(() => { + const timeout = setTimeout(() => { + setAccessToken(publicAccessToken); + }, 2000); + + return () => clearTimeout(timeout); + }, [publicAccessToken]); + const { run, error } = useRealtimeRun(runId, { - accessToken: publicAccessToken, + accessToken, + enabled: accessToken !== undefined, }); if (error) { From 1b34daf53b6dafa3754ac9dbd69ea255da57e82f Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 13:55:37 +0000 Subject: [PATCH 23/44] Batches page --- .../app/components/navigation/SideMenu.tsx | 9 + .../app/components/runs/v3/BatchFilters.tsx | 933 ++++++++++++++++++ .../app/components/runs/v3/BatchStatus.tsx | 81 ++ .../v3/BatchListPresenter.server.ts | 210 ++++ .../route.tsx | 252 +++++ apps/webapp/app/utils/pathBuilder.ts | 12 + .../database/prisma/schema.prisma | 2 +- 7 files changed, 1498 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/app/components/runs/v3/BatchFilters.tsx create mode 100644 apps/webapp/app/components/runs/v3/BatchStatus.tsx create mode 100644 apps/webapp/app/presenters/v3/BatchListPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index dd12ddbcdf..668e9f2118 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -12,6 +12,7 @@ import { RectangleStackIcon, ServerStackIcon, ShieldCheckIcon, + Squares2X2Icon, } from "@heroicons/react/20/solid"; import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid"; import { useNavigation } from "@remix-run/react"; @@ -45,6 +46,7 @@ import { projectSetupPath, projectTriggersPath, v3ApiKeysPath, + v3BatchesPath, v3BillingPath, v3ConcurrencyPath, v3DeploymentsPath, @@ -475,6 +477,13 @@ function V3ProjectSideMenu({ activeIconColor="text-teal-500" to={v3RunsPath(organization, project)} /> + (typeof value === "string" ? [value] : value), + z.string().array().optional() + ), + statuses: z.preprocess( + (value) => (typeof value === "string" ? [value] : value), + BatchStatus.array().optional() + ), + period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), + id: z.string().optional(), + from: z.coerce.number().optional(), + to: z.coerce.number().optional(), +}); + +export type BatchListFilters = z.infer; + +type DisplayableEnvironment = Pick & { + userName?: string; +}; + +type BatchFiltersProps = { + possibleEnvironments: DisplayableEnvironment[]; + hasFilters: boolean; +}; + +export function BatchFilters(props: BatchFiltersProps) { + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const hasFilters = + searchParams.has("statuses") || + searchParams.has("environments") || + searchParams.has("id") || + searchParams.has("period") || + searchParams.has("from") || + searchParams.has("to"); + + return ( +
+ + + {hasFilters && ( + + {searchParams.has("showChildTasks") && ( + + )} + + + )} +
+ ); +} + +const filterTypes = [ + { + name: "statuses", + title: "Status", + icon: ( +
+
+
+ ), + }, + { name: "environments", title: "Environment", icon: }, + { name: "created", title: "Created", icon: }, + { name: "daterange", title: "Custom date range", icon: }, + { name: "batch", title: "Batch ID", icon: }, +] as const; + +type FilterType = (typeof filterTypes)[number]["name"]; + +const shortcut = { key: "f" }; + +function FilterMenu(props: BatchFiltersProps) { + const [filterType, setFilterType] = useState(); + + const filterTrigger = ( + + +
+ } + variant={"minimal/small"} + shortcut={shortcut} + tooltipTitle={"Filter runs"} + > + Filter + + ); + + return ( + setFilterType(undefined)}> + {(search, setSearch) => ( + setSearch("")} + trigger={filterTrigger} + filterType={filterType} + setFilterType={setFilterType} + {...props} + /> + )} + + ); +} + +function FilterMenuProvider({ + children, + onClose, +}: { + children: (search: string, setSearch: (value: string) => void) => React.ReactNode; + onClose?: () => void; +}) { + const [searchValue, setSearchValue] = useState(""); + + return ( + { + startTransition(() => { + setSearchValue(value); + }); + }} + setOpen={(open) => { + if (!open && onClose) { + onClose(); + } + }} + > + {children(searchValue, setSearchValue)} + + ); +} + +function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) { + return ( + <> + + + + + + + ); +} + +type MenuProps = { + searchValue: string; + clearSearchValue: () => void; + trigger: React.ReactNode; + filterType: FilterType | undefined; + setFilterType: (filterType: FilterType | undefined) => void; +} & BatchFiltersProps; + +function Menu(props: MenuProps) { + switch (props.filterType) { + case undefined: + return ; + case "statuses": + return props.setFilterType(undefined)} {...props} />; + case "environments": + return props.setFilterType(undefined)} {...props} />; + case "created": + return props.setFilterType(undefined)} {...props} />; + case "daterange": + return props.setFilterType(undefined)} {...props} />; + case "batch": + return props.setFilterType(undefined)} {...props} />; + } +} + +function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { + const filtered = useMemo(() => { + return filterTypes.filter((item) => { + if (item.name === "daterange") return false; + return item.title.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [searchValue]); + + return ( + + {trigger} + + + + {filtered.map((type, index) => ( + { + clearSearchValue(); + setFilterType(type.name); + }} + icon={type.icon} + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {type.title} + + ))} + + + + ); +} + +const statuses = allBatchStatuses.map((status) => ({ + title: batchStatusTitle(status), + value: status, +})); + +function StatusDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ statuses: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase())); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + + + + + + {descriptionForBatchStatus(item.value)} + + + + + + ))} + + + + ); +} + +function AppliedStatusFilter() { + const { values, del } = useSearchParams(); + const statuses = values("statuses"); + + if (statuses.length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + batchStatusTitle(v as BatchTaskRunStatus)) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function EnvironmentsDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + possibleEnvironments, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + possibleEnvironments: DisplayableEnvironment[]; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ environments: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return possibleEnvironments.filter((item) => { + const title = environmentTitle(item, item.userName); + return title.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [searchValue, possibleEnvironments]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + ))} + + + + ); +} + +function AppliedEnvironmentFilter({ + possibleEnvironments, +}: Pick) { + const { values, del } = useSearchParams(); + + if (values("environments").length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + { + const environment = possibleEnvironments.find((env) => env.id === v); + return environment ? environmentTitle(environment, environment.userName) : v; + }) + )} + onRemove={() => del(["environments", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + possibleEnvironments={possibleEnvironments} + /> + )} + + ); +} + +const timePeriods = [ + { + label: "Last 5 mins", + value: "5m", + }, + { + label: "Last 30 mins", + value: "30m", + }, + { + label: "Last 1 hour", + value: "1h", + }, + { + label: "Last 6 hours", + value: "6h", + }, + { + label: "Last 1 day", + value: "1d", + }, + { + label: "Last 3 days", + value: "3d", + }, + { + label: "Last 7 days", + value: "7d", + }, + { + label: "Last 14 days", + value: "14d", + }, + { + label: "Last 30 days", + value: "30d", + }, + { + label: "All periods", + value: "all", + }, +]; + +function CreatedAtDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + setFilterType, + hideCustomRange, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + setFilterType?: (type: FilterType | undefined) => void; + hideCustomRange?: boolean; +}) { + const { value, replace } = useSearchParams(); + + const from = value("from"); + const to = value("to"); + const period = value("period"); + + const handleChange = (newValue: string) => { + clearSearchValue(); + if (newValue === "all") { + if (!period && !from && !to) return; + + replace({ + period: undefined, + from: undefined, + to: undefined, + cursor: undefined, + direction: undefined, + }); + return; + } + + if (newValue === "custom") { + setFilterType?.("daterange"); + return; + } + + replace({ + period: newValue, + from: undefined, + to: undefined, + cursor: undefined, + direction: undefined, + }); + }; + + const filtered = useMemo(() => { + return timePeriods.filter((item) => + item.label.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item) => ( + + {item.label} + + ))} + {!hideCustomRange ? ( + + Custom date range + + ) : null} + + + + ); +} + +function AppliedPeriodFilter() { + const { value, del } = useSearchParams(); + + if (value("period") === undefined || value("period") === "all") { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + t.value === value("period"))?.label ?? value("period") + } + onRemove={() => del(["period", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + hideCustomRange + /> + )} + + ); +} + +function CustomDateRangeDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const fromSearch = dateFromString(value("from")); + const toSearch = dateFromString(value("to")); + const [from, setFrom] = useState(fromSearch); + const [to, setTo] = useState(toSearch); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + period: undefined, + cursor: undefined, + direction: undefined, + from: from?.getTime().toString(), + to: to?.getTime().toString(), + }); + + setOpen(false); + }, [from, to, replace]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +function AppliedCustomDateRangeFilter() { + const { value, del } = useSearchParams(); + + if (value("from") === undefined && value("to") === undefined) { + return null; + } + + const fromDate = dateFromString(value("from")); + const toDate = dateFromString(value("to")); + + const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to"; + + return ( + + {(search, setSearch) => ( + }> + + {rangeType === "range" ? ( + + –{" "} + + + ) : rangeType === "from" ? ( + + ) : ( + + )} + + } + onRemove={() => del(["period", "from", "to", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function BatchIdDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const batchIdValue = value("batchId"); + + const [batchId, setBatchId] = useState(batchIdValue); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + cursor: undefined, + direction: undefined, + batchId: batchId === "" ? undefined : batchId?.toString(), + }); + + setOpen(false); + }, [batchId, replace]); + + let error: string | undefined = undefined; + if (batchId) { + if (!batchId.startsWith("batch_")) { + error = "Batch IDs start with 'batch_'"; + } else if (batchId.length !== 27) { + error = "Batch IDs are 27 characters long"; + } + } + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setBatchId(e.target.value)} + variant="small" + className="w-[29ch] font-mono" + spellCheck={false} + /> + {error ? {error} : null} +
+
+ + +
+
+
+
+ ); +} + +function AppliedBatchIdFilter() { + const { value, del } = useSearchParams(); + + if (value("batchId") === undefined) { + return null; + } + + const batchId = value("batchId"); + + return ( + + {(search, setSearch) => ( + }> + del(["batchId", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function dateFromString(value: string | undefined | null): Date | undefined { + if (!value) return; + + //is it an int? + const int = parseInt(value); + if (!isNaN(int)) { + return new Date(int); + } + + return new Date(value); +} + +function appliedSummary(values: string[], maxValues = 3) { + if (values.length === 0) { + return null; + } + + if (values.length > maxValues) { + return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`; + } + + return values.join(", "); +} diff --git a/apps/webapp/app/components/runs/v3/BatchStatus.tsx b/apps/webapp/app/components/runs/v3/BatchStatus.tsx new file mode 100644 index 0000000000..c9d5973f5b --- /dev/null +++ b/apps/webapp/app/components/runs/v3/BatchStatus.tsx @@ -0,0 +1,81 @@ +import { CheckCircleIcon } from "@heroicons/react/20/solid"; +import { BatchTaskRunStatus } from "@trigger.dev/database"; +import assertNever from "assert-never"; +import { Spinner } from "~/components/primitives/Spinner"; +import { cn } from "~/utils/cn"; + +export const allBatchStatuses = ["PENDING", "COMPLETED"] as const satisfies Readonly< + Array +>; + +const descriptions: Record = { + PENDING: "The batch has child runs that have not yet completed.", + COMPLETED: "All the batch child runs have finished.", +}; + +export function descriptionForBatchStatus(status: BatchTaskRunStatus): string { + return descriptions[status]; +} + +export function BatchStatusCombo({ + status, + className, + iconClassName, +}: { + status: BatchTaskRunStatus; + className?: string; + iconClassName?: string; +}) { + return ( + + + + + ); +} + +export function BatchStatusLabel({ status }: { status: BatchTaskRunStatus }) { + return {batchStatusTitle(status)}; +} + +export function BatchStatusIcon({ + status, + className, +}: { + status: BatchTaskRunStatus; + className: string; +}) { + switch (status) { + case "PENDING": + return ; + case "COMPLETED": + return ; + default: { + assertNever(status); + } + } +} + +export function batchStatusColor(status: BatchTaskRunStatus): string { + switch (status) { + case "PENDING": + return "text-pending"; + case "COMPLETED": + return "text-success"; + default: { + assertNever(status); + } + } +} + +export function batchStatusTitle(status: BatchTaskRunStatus): string { + switch (status) { + case "PENDING": + return "Pending"; + case "COMPLETED": + return "Completed"; + default: { + assertNever(status); + } + } +} diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts new file mode 100644 index 0000000000..907d68b95c --- /dev/null +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -0,0 +1,210 @@ +import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database"; +import parse from "parse-duration"; +import { type Direction } from "~/components/runs/RunStatuses"; +import { sqlDatabaseSchema } from "~/db.server"; +import { displayableEnvironment } from "~/models/runtimeEnvironment.server"; +import { BasePresenter } from "./basePresenter.server"; + +export type BatchListOptions = { + userId?: string; + projectId: string; + //filters + friendlyId?: string; + statuses?: BatchTaskRunStatus[]; + environments?: string[]; + period?: string; + from?: number; + to?: number; + //pagination + direction?: Direction; + cursor?: string; + pageSize?: number; +}; + +const DEFAULT_PAGE_SIZE = 25; + +export type BatchList = Awaited>; +export type BatchListItem = BatchList["batches"][0]; +export type BatchListAppliedFilters = BatchList["filters"]; + +export class BatchListPresenter extends BasePresenter { + public async call({ + userId, + projectId, + friendlyId, + statuses, + environments, + period, + from, + to, + direction = "forward", + cursor, + pageSize = DEFAULT_PAGE_SIZE, + }: BatchListOptions) { + const hasStatusFilters = statuses && statuses.length > 0; + + const hasFilters = + hasStatusFilters || + (environments !== undefined && environments.length > 0) || + (period !== undefined && period !== "all") || + friendlyId !== undefined || + from !== undefined || + to !== undefined; + + // Find the project scoped to the organization + const project = await this._replica.project.findFirstOrThrow({ + select: { + id: true, + environments: { + select: { + id: true, + type: true, + slug: true, + orgMember: { + select: { + user: { + select: { + id: true, + name: true, + displayName: true, + }, + }, + }, + }, + }, + }, + }, + where: { + id: projectId, + }, + }); + + let environmentIds = project.environments.map((e) => e.id); + if (environments && environments.length > 0) { + environmentIds = environments; + } + + const periodMs = period ? parse(period) : undefined; + + //get the batches + const batches = await this._replica.$queryRaw< + { + id: string; + friendlyId: string; + runtimeEnvironmentId: string; + status: BatchTaskRunStatus; + createdAt: Date; + updatedAt: Date; + runCount: BigInt; + }[] + >` + SELECT + b.id, + b."friendlyId", + b."runtimeEnvironmentId", + b.status, + b."createdAt", + b."updatedAt", + b."runCount" +FROM + ${sqlDatabaseSchema}."BatchTaskRun" b +WHERE + -- environments + b."runtimeEnvironmentId" IN (${Prisma.join(environmentIds)}) + -- cursor + ${ + cursor + ? direction === "forward" + ? Prisma.sql`AND b.id < ${cursor}` + : Prisma.sql`AND b.id > ${cursor}` + : Prisma.empty + } + -- filters + ${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty} + ${ + statuses && statuses.length > 0 + ? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(statuses)}]::"BatchTaskRunStatus"[])` + : Prisma.empty + } + ${ + periodMs + ? Prisma.sql`AND b."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}` + : Prisma.empty + } + ${ + from + ? Prisma.sql`AND b."createdAt" >= ${new Date(from).toISOString()}::timestamp` + : Prisma.empty + } + ${to ? Prisma.sql`AND b."createdAt" <= ${new Date(to).toISOString()}::timestamp` : Prisma.empty} + ORDER BY + ${direction === "forward" ? Prisma.sql`b.id DESC` : Prisma.sql`b.id ASC`} + LIMIT ${pageSize + 1}`; + + const hasMore = batches.length > pageSize; + + //get cursors for next and previous pages + let next: string | undefined; + let previous: string | undefined; + switch (direction) { + case "forward": + previous = cursor ? batches.at(0)?.id : undefined; + if (hasMore) { + next = batches[pageSize - 1]?.id; + } + break; + case "backward": + batches.reverse(); + if (hasMore) { + previous = batches[1]?.id; + next = batches[pageSize]?.id; + } else { + next = batches[pageSize - 1]?.id; + } + break; + } + + const batchesToReturn = + direction === "backward" && hasMore + ? batches.slice(1, pageSize + 1) + : batches.slice(0, pageSize); + + return { + batches: batchesToReturn.map((batch) => { + const environment = project.environments.find( + (env) => env.id === batch.runtimeEnvironmentId + ); + + if (!environment) { + throw new Error(`Environment not found for Batch ${batch.id}`); + } + + const hasFinished = batch.status === "COMPLETED"; + + return { + id: batch.id, + friendlyId: batch.friendlyId, + createdAt: batch.createdAt.toISOString(), + updatedAt: batch.updatedAt.toISOString(), + hasFinished, + finishedAt: hasFinished ? batch.updatedAt.toISOString() : undefined, + status: batch.status, + environment: displayableEnvironment(environment, userId), + runCount: Number(batch.runCount), + }; + }), + pagination: { + next, + previous, + }, + filters: { + friendlyId, + statuses: statuses || [], + environments: environments || [], + from, + to, + }, + hasFilters, + }; + } +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx new file mode 100644 index 0000000000..79b1c1686d --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx @@ -0,0 +1,252 @@ +import { CheckCircleIcon, ClockIcon, RectangleGroupIcon } from "@heroicons/react/20/solid"; +import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; +import { BookOpenIcon } from "@heroicons/react/24/solid"; +import { Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; +import { LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { Feedback } from "~/components/Feedback"; +import { ListPagination } from "~/components/ListPagination"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { InlineCode } from "~/components/code/InlineCode"; +import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel"; +import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Header3 } from "~/components/primitives/Headers"; +import { InfoPanel } from "~/components/primitives/InfoPanel"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { PaginationControls } from "~/components/primitives/Pagination"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { Spinner } from "~/components/primitives/Spinner"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableCellChevron, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { SimpleTooltip } from "~/components/primitives/Tooltip"; +import { BatchFilters, BatchListFilters } from "~/components/runs/v3/BatchFilters"; +import { + allBatchStatuses, + BatchStatusCombo, + descriptionForBatchStatus, +} from "~/components/runs/v3/BatchStatus"; +import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; +import { LiveTimer } from "~/components/runs/v3/LiveTimer"; +import { ScheduleFilters } from "~/components/runs/v3/ScheduleFilters"; +import { + ScheduleTypeCombo, + ScheduleTypeIcon, + scheduleTypeName, +} from "~/components/runs/v3/ScheduleType"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { useUser } from "~/hooks/useUser"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server"; +import { type ScheduleListItem } from "~/presenters/v3/ScheduleListPresenter.server"; +import { requireUserId } from "~/services/session.server"; +import { + ProjectParamSchema, + docsPath, + v3BatchRunsPath, + v3BillingPath, + v3NewSchedulePath, + v3SchedulePath, +} from "~/utils/pathBuilder"; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug } = ProjectParamSchema.parse(params); + + const url = new URL(request.url); + const s = { + cursor: url.searchParams.get("cursor") ?? undefined, + direction: url.searchParams.get("direction") ?? undefined, + environments: url.searchParams.getAll("environments"), + statuses: url.searchParams.getAll("statuses"), + period: url.searchParams.get("period") ?? undefined, + from: url.searchParams.get("from") ?? undefined, + to: url.searchParams.get("to") ?? undefined, + id: url.searchParams.get("batchId") ?? undefined, + }; + const filters = BatchListFilters.parse(s); + + const project = await findProjectBySlug(organizationSlug, projectParam, userId); + + if (!project) { + return redirectWithErrorMessage("/", request, "Project not found"); + } + + const presenter = new BatchListPresenter(); + const list = await presenter.call({ + userId, + projectId: project.id, + ...filters, + friendlyId: filters.id, + }); + + return typedjson(list); +}; + +export default function Page() { + const { batches, hasFilters, filters, pagination } = useTypedLoaderData(); + const project = useProject(); + + return ( + + + + + + + + Batches docs + + + + +
+
+ +
+ +
+
+ + +
+
+
+ ); +} + +function BatchesTable({ batches, hasFilters, filters }: BatchList) { + const navigation = useNavigation(); + const isLoading = navigation.state !== "idle"; + const organization = useOrganization(); + const project = useProject(); + + return ( + + + + Batch ID + Env + + {allBatchStatuses.map((status) => ( +
+
+ +
+ + {descriptionForBatchStatus(status)} + +
+ ))} + + } + > + Status +
+ Runs + Duration + Created + Finished + + Go to page + +
+
+ + {batches.length === 0 && !hasFilters ? ( + + {!isLoading && ( +
+ No batches +
+ )} +
+ ) : batches.length === 0 ? ( + +
+ No batches match these filters +
+
+ ) : ( + batches.map((batch, index) => { + const path = v3BatchRunsPath(organization, project, batch); + return ( + + {batch.friendlyId} + + + + + } + /> + + {batch.runCount} + + {batch.finishedAt ? ( + formatDuration(new Date(batch.createdAt), new Date(batch.finishedAt), { + style: "short", + }) + ) : ( + + )} + + + + + + {batch.finishedAt ? : "–"} + + + + ); + }) + )} + {isLoading && ( + + Loading… + + )} +
+
+ ); +} diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index cf71810be7..20392173c4 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -437,6 +437,18 @@ export function v3NewSchedulePath(organization: OrgForPath, project: ProjectForP return `${v3ProjectPath(organization, project)}/schedules/new`; } +export function v3BatchesPath(organization: OrgForPath, project: ProjectForPath) { + return `${v3ProjectPath(organization, project)}/batches`; +} + +export function v3BatchRunsPath( + organization: OrgForPath, + project: ProjectForPath, + batch: { friendlyId: string } +) { + return `${v3ProjectPath(organization, project)}/runs?batchId=${batch.friendlyId}`; +} + export function v3ProjectSettingsPath(organization: OrgForPath, project: ProjectForPath) { return `${v3ProjectPath(organization, project)}/settings`; } diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 7bec6e040d..5b26f4a465 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2147,6 +2147,7 @@ model BatchTaskRun { idempotencyKey String? idempotencyKeyExpiresAt DateTime? runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + status BatchTaskRunStatus @default(PENDING) runtimeEnvironmentId String runs TaskRun[] createdAt DateTime @default(now()) @@ -2161,7 +2162,6 @@ model BatchTaskRun { ///all the below properties are engine v1 only items BatchTaskRunItem[] - status BatchTaskRunStatus @default(PENDING) taskIdentifier String? checkpointEvent CheckpointRestoreEvent? @relation(fields: [checkpointEventId], references: [id], onDelete: Cascade, onUpdate: Cascade) checkpointEventId String? @unique From 2ebc4e57eafbff9ba3a572efdf9a5a4ca0db08a9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 27 Nov 2024 15:50:18 +0000 Subject: [PATCH 24/44] Always complete batches, not only batchTriggerAndWait in deployed tasks --- .../app/v3/services/batchTriggerV2.server.ts | 6 +- .../app/v3/services/finalizeTaskRun.server.ts | 76 ++++++++++++ .../app/v3/services/resumeBatchRun.server.ts | 111 ++++++++++++------ .../migration.sql | 2 + .../database/prisma/schema.prisma | 11 +- references/v3-catalog/src/management.ts | 12 +- references/v3-catalog/src/trigger/batch.ts | 34 ------ references/v3-catalog/src/trigger/subtasks.ts | 2 - 8 files changed, 172 insertions(+), 82 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20241127153804_add_batch_version_to_batch_task_run/migration.sql diff --git a/apps/webapp/app/v3/services/batchTriggerV2.server.ts b/apps/webapp/app/v3/services/batchTriggerV2.server.ts index 3b3250086e..cbe2a5421c 100644 --- a/apps/webapp/app/v3/services/batchTriggerV2.server.ts +++ b/apps/webapp/app/v3/services/batchTriggerV2.server.ts @@ -229,6 +229,8 @@ export class BatchTriggerV2Service extends BaseService { dependentTaskAttemptId: dependentAttempt?.id, runCount: body.items.length, runIds: runs.map((r) => r.id), + status: "COMPLETED", + batchVersion: "v2", }, }); @@ -328,11 +330,12 @@ export class BatchTriggerV2Service extends BaseService { idempotencyKey: options.idempotencyKey, idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, dependentTaskAttemptId: dependentAttempt?.id, - runCount: body.items.length, + runCount: newRunCount, runIds: runs.map((r) => r.id), payload: payloadPacket.data, payloadType: payloadPacket.dataType, options, + batchVersion: "v2", }, }); @@ -409,6 +412,7 @@ export class BatchTriggerV2Service extends BaseService { payload: payloadPacket.data, payloadType: payloadPacket.dataType, options, + batchVersion: "v2", }, }); diff --git a/apps/webapp/app/v3/services/finalizeTaskRun.server.ts b/apps/webapp/app/v3/services/finalizeTaskRun.server.ts index e92beaaa50..966d610684 100644 --- a/apps/webapp/app/v3/services/finalizeTaskRun.server.ts +++ b/apps/webapp/app/v3/services/finalizeTaskRun.server.ts @@ -14,6 +14,7 @@ import { BaseService } from "./baseService.server"; import { ResumeDependentParentsService } from "./resumeDependentParents.server"; import { ExpireEnqueuedRunService } from "./expireEnqueuedRun.server"; import { socketIo } from "../handleSocketIo.server"; +import { ResumeBatchRunService } from "./resumeBatchRun.server"; type BaseInput = { id: string; @@ -81,6 +82,15 @@ export class FinalizeTaskRunService extends BaseService { await this.finalizeRunError(run, error); } + try { + await this.#finalizeBatch(run); + } catch (finalizeBatchError) { + logger.error("FinalizeTaskRunService: Failed to finalize batch", { + runId: run.id, + error: finalizeBatchError, + }); + } + //resume any dependencies const resumeService = new ResumeDependentParentsService(this._prisma); const result = await resumeService.call({ id: run.id }); @@ -135,6 +145,72 @@ export class FinalizeTaskRunService extends BaseService { return run as Output; } + async #finalizeBatch(run: TaskRun) { + if (!run.batchId) { + return; + } + + logger.debug("FinalizeTaskRunService: Finalizing batch", { runId: run.id }); + + const environment = await this._prisma.runtimeEnvironment.findFirst({ + where: { + id: run.runtimeEnvironmentId, + }, + }); + + if (!environment) { + return; + } + + const batchItems = await this._prisma.batchTaskRunItem.findMany({ + where: { + taskRunId: run.id, + }, + include: { + batchTaskRun: { + select: { + id: true, + dependentTaskAttemptId: true, + }, + }, + }, + }); + + if (batchItems.length === 0) { + return; + } + + if (batchItems.length > 10) { + logger.error("FinalizeTaskRunService: More than 10 batch items", { + runId: run.id, + batchItems: batchItems.length, + }); + + return; + } + + for (const item of batchItems) { + // Don't do anything if this is a batchTriggerAndWait in a deployed task + if (environment.type !== "DEVELOPMENT" && item.batchTaskRun.dependentTaskAttemptId) { + continue; + } + + // Update the item to complete + await this._prisma.batchTaskRunItem.update({ + where: { + id: item.id, + }, + data: { + status: "COMPLETED", + }, + }); + + // This won't resume because this batch does not have a dependent task attempt ID + // or is in development, but this service will mark the batch as completed + await ResumeBatchRunService.enqueue(item.batchTaskRunId, this._prisma); + } + } + async finalizeRunError(run: TaskRun, error: TaskRunError) { await this._prisma.taskRun.update({ where: { id: run.id }, diff --git a/apps/webapp/app/v3/services/resumeBatchRun.server.ts b/apps/webapp/app/v3/services/resumeBatchRun.server.ts index 4be9cbc379..5089e96103 100644 --- a/apps/webapp/app/v3/services/resumeBatchRun.server.ts +++ b/apps/webapp/app/v3/services/resumeBatchRun.server.ts @@ -13,26 +13,26 @@ export class ResumeBatchRunService extends BaseService { id: batchRunId, }, include: { - dependentTaskAttempt: { + runtimeEnvironment: { include: { - runtimeEnvironment: { - include: { - project: true, - organization: true, - }, - }, - taskRun: true, + project: true, + organization: true, + }, + }, + items: { + select: { + status: true, + taskRunAttemptId: true, }, }, - items: true, }, }); - if (!batchRun || !batchRun.dependentTaskAttempt) { + if (!batchRun) { logger.error( "ResumeBatchRunService: Batch run doesn't exist or doesn't have a dependent attempt", { - batchRun, + batchRunId, } ); return; @@ -40,23 +40,28 @@ export class ResumeBatchRunService extends BaseService { if (batchRun.status === "COMPLETED") { logger.debug("ResumeBatchRunService: Batch run is already completed", { - batchRun: batchRun, + batchRunId: batchRun.id, + batchRun: { + id: batchRun.id, + status: batchRun.status, + }, }); return; } if (batchRun.items.some((item) => !finishedBatchRunStatuses.includes(item.status))) { logger.debug("ResumeBatchRunService: All items aren't yet completed", { - batchRun: batchRun, + batchRunId: batchRun.id, + batchRun: { + id: batchRun.id, + status: batchRun.status, + }, }); return; } - // This batch has a dependent attempt and just finalized, we should resume that attempt - const environment = batchRun.dependentTaskAttempt.runtimeEnvironment; - - // If we are in development, we don't need to resume the dependent task (that will happen automatically) - if (environment.type === "DEVELOPMENT") { + // If we are in development, or there is no dependent attempt, we can just mark the batch as completed and return + if (batchRun.runtimeEnvironment.type === "DEVELOPMENT" || !batchRun.dependentTaskAttemptId) { // We need to update the batchRun status so we don't resume it again await this._prisma.batchTaskRun.update({ where: { @@ -69,12 +74,42 @@ export class ResumeBatchRunService extends BaseService { return; } - const dependentRun = batchRun.dependentTaskAttempt.taskRun; + const dependentTaskAttempt = await this._prisma.taskRunAttempt.findFirst({ + where: { + id: batchRun.dependentTaskAttemptId, + }, + select: { + status: true, + id: true, + taskRun: { + select: { + id: true, + queue: true, + taskIdentifier: true, + concurrencyKey: true, + }, + }, + }, + }); + + if (!dependentTaskAttempt) { + logger.error("ResumeBatchRunService: Dependent attempt not found", { + batchRunId: batchRun.id, + dependentTaskAttemptId: batchRun.dependentTaskAttemptId, + }); + + return; + } + + // This batch has a dependent attempt and just finalized, we should resume that attempt + const environment = batchRun.runtimeEnvironment; + + const dependentRun = dependentTaskAttempt.taskRun; - if (batchRun.dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) { + if (dependentTaskAttempt.status === "PAUSED" && batchRun.checkpointEventId) { logger.debug("ResumeBatchRunService: Attempt is paused and has a checkpoint event", { batchRunId: batchRun.id, - dependentTaskAttempt: batchRun.dependentTaskAttempt, + dependentTaskAttempt: dependentTaskAttempt, checkpointEventId: batchRun.checkpointEventId, }); @@ -83,7 +118,7 @@ export class ResumeBatchRunService extends BaseService { if (wasUpdated) { logger.debug("ResumeBatchRunService: Resuming dependent run with checkpoint", { batchRunId: batchRun.id, - dependentTaskAttemptId: batchRun.dependentTaskAttempt.id, + dependentTaskAttemptId: dependentTaskAttempt.id, }); await marqs?.enqueueMessage( environment, @@ -92,19 +127,19 @@ export class ResumeBatchRunService extends BaseService { { type: "RESUME", completedAttemptIds: [], - resumableAttemptId: batchRun.dependentTaskAttempt.id, + resumableAttemptId: dependentTaskAttempt.id, checkpointEventId: batchRun.checkpointEventId, - taskIdentifier: batchRun.dependentTaskAttempt.taskRun.taskIdentifier, - projectId: batchRun.dependentTaskAttempt.runtimeEnvironment.projectId, - environmentId: batchRun.dependentTaskAttempt.runtimeEnvironment.id, - environmentType: batchRun.dependentTaskAttempt.runtimeEnvironment.type, + taskIdentifier: dependentTaskAttempt.taskRun.taskIdentifier, + projectId: environment.projectId, + environmentId: environment.id, + environmentType: environment.type, }, dependentRun.concurrencyKey ?? undefined ); } else { logger.debug("ResumeBatchRunService: with checkpoint was already completed", { batchRunId: batchRun.id, - dependentTaskAttempt: batchRun.dependentTaskAttempt, + dependentTaskAttempt: dependentTaskAttempt, checkpointEventId: batchRun.checkpointEventId, hasCheckpointEvent: !!batchRun.checkpointEventId, }); @@ -112,17 +147,17 @@ export class ResumeBatchRunService extends BaseService { } else { logger.debug("ResumeBatchRunService: attempt is not paused or there's no checkpoint event", { batchRunId: batchRun.id, - dependentTaskAttempt: batchRun.dependentTaskAttempt, + dependentTaskAttempt: dependentTaskAttempt, checkpointEventId: batchRun.checkpointEventId, hasCheckpointEvent: !!batchRun.checkpointEventId, }); - if (batchRun.dependentTaskAttempt.status === "PAUSED" && !batchRun.checkpointEventId) { + if (dependentTaskAttempt.status === "PAUSED" && !batchRun.checkpointEventId) { // In case of race conditions the status can be PAUSED without a checkpoint event // When the checkpoint is created, it will continue the run logger.error("ResumeBatchRunService: attempt is paused but there's no checkpoint event", { batchRunId: batchRun.id, - dependentTaskAttempt: batchRun.dependentTaskAttempt, + dependentTaskAttempt: dependentTaskAttempt, checkpointEventId: batchRun.checkpointEventId, hasCheckpointEvent: !!batchRun.checkpointEventId, }); @@ -134,24 +169,24 @@ export class ResumeBatchRunService extends BaseService { if (wasUpdated) { logger.debug("ResumeBatchRunService: Resuming dependent run without checkpoint", { batchRunId: batchRun.id, - dependentTaskAttempt: batchRun.dependentTaskAttempt, + dependentTaskAttempt: dependentTaskAttempt, checkpointEventId: batchRun.checkpointEventId, hasCheckpointEvent: !!batchRun.checkpointEventId, }); await marqs?.replaceMessage(dependentRun.id, { type: "RESUME", completedAttemptIds: batchRun.items.map((item) => item.taskRunAttemptId).filter(Boolean), - resumableAttemptId: batchRun.dependentTaskAttempt.id, + resumableAttemptId: dependentTaskAttempt.id, checkpointEventId: batchRun.checkpointEventId ?? undefined, - taskIdentifier: batchRun.dependentTaskAttempt.taskRun.taskIdentifier, - projectId: batchRun.dependentTaskAttempt.runtimeEnvironment.projectId, - environmentId: batchRun.dependentTaskAttempt.runtimeEnvironment.id, - environmentType: batchRun.dependentTaskAttempt.runtimeEnvironment.type, + taskIdentifier: dependentTaskAttempt.taskRun.taskIdentifier, + projectId: environment.projectId, + environmentId: environment.id, + environmentType: environment.type, }); } else { logger.debug("ResumeBatchRunService: without checkpoint was already completed", { batchRunId: batchRun.id, - dependentTaskAttempt: batchRun.dependentTaskAttempt, + dependentTaskAttempt: dependentTaskAttempt, checkpointEventId: batchRun.checkpointEventId, hasCheckpointEvent: !!batchRun.checkpointEventId, }); diff --git a/internal-packages/database/prisma/migrations/20241127153804_add_batch_version_to_batch_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241127153804_add_batch_version_to_batch_task_run/migration.sql new file mode 100644 index 0000000000..df3edf6e97 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241127153804_add_batch_version_to_batch_task_run/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BatchTaskRun" ADD COLUMN "batchVersion" TEXT NOT NULL DEFAULT 'v1'; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index 5b26f4a465..143f2203c3 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -2154,11 +2154,12 @@ model BatchTaskRun { updatedAt DateTime @updatedAt // new columns - runIds String[] @default([]) - runCount Int @default(0) - payload String? - payloadType String @default("application/json") - options Json? + runIds String[] @default([]) + runCount Int @default(0) + payload String? + payloadType String @default("application/json") + options Json? + batchVersion String @default("v1") ///all the below properties are engine v1 only items BatchTaskRunItem[] diff --git a/references/v3-catalog/src/management.ts b/references/v3-catalog/src/management.ts index cb12c947e9..11ff9b7567 100644 --- a/references/v3-catalog/src/management.ts +++ b/references/v3-catalog/src/management.ts @@ -1,4 +1,4 @@ -import { configure, envvars, runs, schedules } from "@trigger.dev/sdk/v3"; +import { configure, envvars, runs, schedules, batch } from "@trigger.dev/sdk/v3"; import dotenv from "dotenv"; import { unfriendlyIdTask } from "./trigger/other.js"; import { spamRateLimiter, taskThatErrors } from "./trigger/retries.js"; @@ -255,9 +255,17 @@ async function doTriggerUnfriendlyTaskId() { console.log("completed run", completedRun); } +async function doBatchTrigger() { + const response = await batch.triggerByTask([ + { task: simpleChildTask, payload: { message: "Hello, World!" } }, + ]); + + console.log("batch trigger response", response); +} + // doRuns().catch(console.error); // doListRuns().catch(console.error); // doScheduleLists().catch(console.error); -doSchedules().catch(console.error); +doBatchTrigger().catch(console.error); // doEnvVars().catch(console.error); // doTriggerUnfriendlyTaskId().catch(console.error); diff --git a/references/v3-catalog/src/trigger/batch.ts b/references/v3-catalog/src/trigger/batch.ts index b7a12b3415..875778540a 100644 --- a/references/v3-catalog/src/trigger/batch.ts +++ b/references/v3-catalog/src/trigger/batch.ts @@ -633,40 +633,6 @@ export const batchV2TestTask = task({ logger.debug("All runs", { runsById: Object.fromEntries(runsById) }); assert.equal(runsById.size, 100, "All runs were not received"); - - // Now use tasks.batchTrigger with 100 items - const response13 = await tasks.batchTrigger( - "batch-v2-test-child", - Array.from({ length: 500 }, (_, i) => ({ - payload: { foo: `bar${i}` }, - })) - ); - - const response13Start = performance.now(); - - logger.debug("Response 13", { response13 }); - - assert.match(response13.batchId, /^batch_[a-z0-9]{21}$/, "response13: Batch ID is invalid"); - assert.equal(response13.runs.length, 500, "response13: Items length is invalid"); - - runsById.clear(); - - for await (const run of runs.subscribeToBatch(response13.batchId)) { - runsById.set(run.id, run); - - // Break if we have received all runs - if (runsById.size === response13.runs.length) { - break; - } - } - - const response13End = performance.now(); - - logger.debug("Response 13 time", { time: response13End - response13Start }); - - logger.debug("All runs", { runsById: Object.fromEntries(runsById) }); - - assert.equal(runsById.size, 500, "All runs were not received"); }, }); diff --git a/references/v3-catalog/src/trigger/subtasks.ts b/references/v3-catalog/src/trigger/subtasks.ts index dfff628620..9e976c7725 100644 --- a/references/v3-catalog/src/trigger/subtasks.ts +++ b/references/v3-catalog/src/trigger/subtasks.ts @@ -131,8 +131,6 @@ export const triggerAndWaitLoops = task({ const handle = await taskWithNoPayload.trigger(); await taskWithNoPayload.triggerAndWait(); - await taskWithNoPayload.batchTrigger([{}]); - await taskWithNoPayload.batchTriggerAndWait([{}]); // Don't do this! // await Promise.all( From 658f4001f49fb0f567079955e79c0864eaf1ecd6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 27 Nov 2024 16:11:59 +0000 Subject: [PATCH 25/44] Add batch.retrieve and allow filtering by batch in runs.list --- .../v3/ApiRetrieveBatchPresenter.server.ts | 32 +++++++++++++++ .../v3/ApiRunListPresenter.server.ts | 5 +++ .../app/routes/api.v1.batches.$batchId.ts | 31 ++++++++++++++ packages/core/src/v3/apiClient/index.ts | 17 ++++++++ packages/core/src/v3/apiClient/types.ts | 1 + packages/core/src/v3/schemas/api.ts | 15 +++++++ packages/trigger-sdk/src/v3/batch.ts | 40 +++++++++++++++++++ references/v3-catalog/src/management.ts | 8 ++++ 8 files changed, 149 insertions(+) create mode 100644 apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts create mode 100644 apps/webapp/app/routes/api.v1.batches.$batchId.ts diff --git a/apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts new file mode 100644 index 0000000000..19e0b2973a --- /dev/null +++ b/apps/webapp/app/presenters/v3/ApiRetrieveBatchPresenter.server.ts @@ -0,0 +1,32 @@ +import { RetrieveBatchResponse } from "@trigger.dev/core/v3"; +import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; +import { BasePresenter } from "./basePresenter.server"; + +export class ApiRetrieveBatchPresenter extends BasePresenter { + public async call( + friendlyId: string, + env: AuthenticatedEnvironment + ): Promise { + return this.traceWithEnv("call", env, async (span) => { + const batch = await this._replica.batchTaskRun.findFirst({ + where: { + friendlyId, + runtimeEnvironmentId: env.id, + }, + }); + + if (!batch) { + return; + } + + return { + id: batch.friendlyId, + status: batch.status, + idempotencyKey: batch.idempotencyKey ?? undefined, + createdAt: batch.createdAt, + updatedAt: batch.updatedAt, + runCount: batch.runCount, + }; + }); + } +} diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 745db9fdf0..fe52834efc 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -119,6 +119,7 @@ export const ApiRunListSearchParams = z.object({ "filter[createdAt][from]": CoercedDate, "filter[createdAt][to]": CoercedDate, "filter[createdAt][period]": z.string().optional(), + "filter[batch]": z.string().optional(), }); type ApiRunListSearchParams = z.infer; @@ -209,6 +210,10 @@ export class ApiRunListPresenter extends BasePresenter { options.isTest = searchParams["filter[isTest]"]; } + if (searchParams["filter[batch]"]) { + options.batchId = searchParams["filter[batch]"]; + } + const presenter = new RunListPresenter(); logger.debug("Calling RunListPresenter", { options }); diff --git a/apps/webapp/app/routes/api.v1.batches.$batchId.ts b/apps/webapp/app/routes/api.v1.batches.$batchId.ts new file mode 100644 index 0000000000..02931a1a43 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.batches.$batchId.ts @@ -0,0 +1,31 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { ApiRetrieveBatchPresenter } from "~/presenters/v3/ApiRetrieveBatchPresenter.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + batchId: z.string(), +}); + +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + allowJWT: true, + corsStrategy: "all", + authorization: { + action: "read", + resource: (params) => ({ batch: params.batchId }), + superScopes: ["read:runs", "read:all", "admin"], + }, + }, + async ({ params, authentication }) => { + const presenter = new ApiRetrieveBatchPresenter(); + const result = await presenter.call(params.batchId, authentication.environment); + + if (!result) { + return json({ error: "Batch not found" }, { status: 404 }); + } + + return json(result); + } +); diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 5a195dcab9..23066f3639 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -18,6 +18,7 @@ import { ListScheduleOptions, ReplayRunResponse, RescheduleRunRequestBody, + RetrieveBatchResponse, RetrieveRunResponse, ScheduleObject, TaskRunExecutionResult, @@ -670,6 +671,18 @@ export class ApiClient { ); } + retrieveBatch(batchId: string, requestOptions?: ZodFetchOptions) { + return zodfetch( + RetrieveBatchResponse, + `${this.baseUrl}/api/v1/batches/${batchId}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + #getHeaders(spanParentAsLink: boolean, additionalHeaders?: Record) { const headers: Record = { "Content-Type": "application/json", @@ -793,6 +806,10 @@ function createSearchQueryForListRuns(query?: ListRunsQueryParams): URLSearchPar if (query.period) { searchParams.append("filter[createdAt][period]", query.period); } + + if (query.batch) { + searchParams.append("filter[batch]", query.batch); + } } return searchParams; diff --git a/packages/core/src/v3/apiClient/types.ts b/packages/core/src/v3/apiClient/types.ts index e7c51a1553..45e2f3668f 100644 --- a/packages/core/src/v3/apiClient/types.ts +++ b/packages/core/src/v3/apiClient/types.ts @@ -31,6 +31,7 @@ export interface ListRunsQueryParams extends CursorPageParams { tag?: Array | string; schedule?: string; isTest?: boolean; + batch?: string; } export interface ListProjectRunsQueryParams extends CursorPageParams, ListRunsQueryParams { diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 04d069b2c8..ac40846824 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -712,3 +712,18 @@ export const SubscribeRunRawShape = z.object({ }); export type SubscribeRunRawShape = z.infer; + +export const BatchStatus = z.enum(["PENDING", "COMPLETED"]); + +export type BatchStatus = z.infer; + +export const RetrieveBatchResponse = z.object({ + id: z.string(), + status: BatchStatus, + idempotencyKey: z.string().optional(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + runCount: z.number(), +}); + +export type RetrieveBatchResponse = z.infer; diff --git a/packages/trigger-sdk/src/v3/batch.ts b/packages/trigger-sdk/src/v3/batch.ts index eb8fa9b61e..9b7a5df4c8 100644 --- a/packages/trigger-sdk/src/v3/batch.ts +++ b/packages/trigger-sdk/src/v3/batch.ts @@ -1,13 +1,53 @@ +import { + accessoryAttributes, + apiClientManager, + ApiPromise, + ApiRequestOptions, + mergeRequestOptions, + RetrieveBatchResponse, +} from "@trigger.dev/core/v3"; import { batchTriggerById, batchTriggerByIdAndWait, batchTriggerTasks, batchTriggerAndWaitTasks, } from "./shared.js"; +import { tracer } from "./tracer.js"; export const batch = { trigger: batchTriggerById, triggerAndWait: batchTriggerByIdAndWait, triggerByTask: batchTriggerTasks, triggerByTaskAndWait: batchTriggerAndWaitTasks, + retrieve: retrieveBatch, }; + +function retrieveBatch( + batchId: string, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "batch.retrieve()", + icon: "batch", + attributes: { + batchId: batchId, + ...accessoryAttributes({ + items: [ + { + text: batchId, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + }, + requestOptions + ); + + return apiClient.retrieveBatch(batchId, $requestOptions); +} diff --git a/references/v3-catalog/src/management.ts b/references/v3-catalog/src/management.ts index 11ff9b7567..4fc6c5cc76 100644 --- a/references/v3-catalog/src/management.ts +++ b/references/v3-catalog/src/management.ts @@ -261,6 +261,14 @@ async function doBatchTrigger() { ]); console.log("batch trigger response", response); + + const $batch = await batch.retrieve(response.batchId); + + console.log("batch", $batch); + + const $runs = await runs.list({ batch: response.batchId }); + + console.log("batch runs", $runs.data); } // doRuns().catch(console.error); From ff653108905307da4120764a9123a5830233e682 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 15:52:53 +0000 Subject: [PATCH 26/44] =?UTF-8?q?Renamed=20pending=20to=20=E2=80=9CIn=20pr?= =?UTF-8?q?ogress=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/runs/v3/BatchStatus.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/BatchStatus.tsx b/apps/webapp/app/components/runs/v3/BatchStatus.tsx index c9d5973f5b..b512ac1b3c 100644 --- a/apps/webapp/app/components/runs/v3/BatchStatus.tsx +++ b/apps/webapp/app/components/runs/v3/BatchStatus.tsx @@ -71,7 +71,7 @@ export function batchStatusColor(status: BatchTaskRunStatus): string { export function batchStatusTitle(status: BatchTaskRunStatus): string { switch (status) { case "PENDING": - return "Pending"; + return "In progress"; case "COMPLETED": return "Completed"; default: { From 5f5cbe2564ccad06f93d3539b6734a8757d6e72b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 15:53:00 +0000 Subject: [PATCH 27/44] Tidied up the table a bit --- .../route.tsx | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx index 79b1c1686d..20238de756 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx @@ -151,7 +151,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { - Batch ID + ID Env Duration Created Finished - - Go to page - {batches.length === 0 && !hasFilters ? ( - + {!isLoading && (
No batches @@ -193,7 +190,7 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { )} ) : batches.length === 0 ? ( - +
No batches match these filters
@@ -233,14 +230,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { {batch.finishedAt ? : "–"} - ); }) )} {isLoading && ( Loading… From e60b75b16f4e4ebe4291bbc845b6b44298e72c4e Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 16:21:16 +0000 Subject: [PATCH 28/44] =?UTF-8?q?Deal=20with=20old=20batches:=20=E2=80=9CL?= =?UTF-8?q?egacy=20batch=E2=80=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../v3/BatchListPresenter.server.ts | 9 ++++-- .../route.tsx | 30 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index 907d68b95c..7bb3ea7382 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -96,6 +96,7 @@ export class BatchListPresenter extends BasePresenter { createdAt: Date; updatedAt: Date; runCount: BigInt; + batchVersion: string; }[] >` SELECT @@ -105,7 +106,8 @@ export class BatchListPresenter extends BasePresenter { b.status, b."createdAt", b."updatedAt", - b."runCount" + b."runCount", + b."batchVersion" FROM ${sqlDatabaseSchema}."BatchTaskRun" b WHERE @@ -123,7 +125,9 @@ WHERE ${friendlyId ? Prisma.sql`AND b."friendlyId" = ${friendlyId}` : Prisma.empty} ${ statuses && statuses.length > 0 - ? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join(statuses)}]::"BatchTaskRunStatus"[])` + ? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join( + statuses + )}]::"BatchTaskRunStatus"[]) AND b.version <> 'v1'` : Prisma.empty } ${ @@ -191,6 +195,7 @@ WHERE status: batch.status, environment: displayableEnvironment(environment, userId), runCount: Number(batch.runCount), + batchVersion: batch.batchVersion, }; }), pagination: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx index 20238de756..9031f4ba8a 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx @@ -1,4 +1,9 @@ -import { CheckCircleIcon, ClockIcon, RectangleGroupIcon } from "@heroicons/react/20/solid"; +import { + CheckCircleIcon, + ClockIcon, + ExclamationCircleIcon, + RectangleGroupIcon, +} from "@heroicons/react/20/solid"; import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; import { BookOpenIcon } from "@heroicons/react/24/solid"; import { Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; @@ -208,11 +213,24 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) { /> - } - /> + {batch.batchVersion === "v1" ? ( + + + Legacy batch + + } + /> + ) : ( + } + /> + )} {batch.runCount} From eac6aa702cb19ab7f405e11fcaafbc28f398d57b Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 16:30:53 +0000 Subject: [PATCH 29/44] Added the Batch to the run inspector --- .../app/presenters/v3/SpanPresenter.server.ts | 6 ++++++ .../route.tsx | 17 +++++++++++++++++ apps/webapp/app/utils/pathBuilder.ts | 8 ++++++++ 3 files changed, 31 insertions(+) diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index 348ed704f2..fee3aab63f 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -149,6 +149,11 @@ export class SpanPresenter extends BasePresenter { spanId: true, }, }, + batch: { + select: { + friendlyId: true, + }, + }, }, where: { spanId, @@ -312,6 +317,7 @@ export class SpanPresenter extends BasePresenter { context: JSON.stringify(context, null, 2), metadata, maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds), + batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined, }; } diff --git a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx index a871672684..cefdb4f585 100644 --- a/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx +++ b/apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route.tsx @@ -56,6 +56,8 @@ import { requireUserId } from "~/services/session.server"; import { cn } from "~/utils/cn"; import { formatCurrencyAccurate } from "~/utils/numberFormatter"; import { + v3BatchPath, + v3BatchRunsPath, v3RunDownloadLogsPath, v3RunPath, v3RunSpanPath, @@ -583,6 +585,21 @@ function RunBody({ ) ) : null} + {run.batch && ( + + Batch + + + {run.batch.friendlyId} + + } + content={`Jump to ${run.batch.friendlyId}`} + /> + + + )} Version diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 20392173c4..262dbfbd64 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -441,6 +441,14 @@ export function v3BatchesPath(organization: OrgForPath, project: ProjectForPath) return `${v3ProjectPath(organization, project)}/batches`; } +export function v3BatchPath( + organization: OrgForPath, + project: ProjectForPath, + batch: { friendlyId: string } +) { + return `${v3ProjectPath(organization, project)}/batches?batchId=${batch.friendlyId}`; +} + export function v3BatchRunsPath( organization: OrgForPath, project: ProjectForPath, From 4b2932a71df8ba0915e07a21dcd5d4d198370f3b Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 27 Nov 2024 17:50:40 +0000 Subject: [PATCH 30/44] Fixed the migration that created the new idempotency key index on BatchTaskRun --- .../migration.sql | 24 ++++++++++--------- .../migration.sql | 2 ++ 2 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 internal-packages/database/prisma/migrations/20241120161205_add_new_unique_index_on_batch_task_run/migration.sql diff --git a/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql b/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql index 6951292bde..1e025d690f 100644 --- a/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql +++ b/internal-packages/database/prisma/migrations/20241120161204_modify_batch_task_run_for_improvements/migration.sql @@ -1,16 +1,18 @@ /* - Warnings: - - - A unique constraint covering the columns `[runtimeEnvironmentId,idempotencyKey]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail. - -*/ + Warnings: + + - A unique constraint covering the columns `[runtimeEnvironmentId,idempotencyKey]` on the table `BatchTaskRun` will be added. If there are existing duplicate values, this will fail. + + */ -- DropIndex DROP INDEX "BatchTaskRun_runtimeEnvironmentId_taskIdentifier_idempotenc_key"; -- AlterTable -ALTER TABLE "BatchTaskRun" ADD COLUMN "runCount" INTEGER NOT NULL DEFAULT 0, -ADD COLUMN "runIds" TEXT[] DEFAULT ARRAY[]::TEXT[], -ALTER COLUMN "taskIdentifier" DROP NOT NULL; - --- CreateIndex -CREATE UNIQUE INDEX "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey"); +ALTER TABLE + "BatchTaskRun" +ADD + COLUMN "runCount" INTEGER NOT NULL DEFAULT 0, +ADD + COLUMN "runIds" TEXT [] DEFAULT ARRAY [] :: TEXT [], +ALTER COLUMN + "taskIdentifier" DROP NOT NULL; \ No newline at end of file diff --git a/internal-packages/database/prisma/migrations/20241120161205_add_new_unique_index_on_batch_task_run/migration.sql b/internal-packages/database/prisma/migrations/20241120161205_add_new_unique_index_on_batch_task_run/migration.sql new file mode 100644 index 0000000000..abf55550af --- /dev/null +++ b/internal-packages/database/prisma/migrations/20241120161205_add_new_unique_index_on_batch_task_run/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS "BatchTaskRun_runtimeEnvironmentId_idempotencyKey_key" ON "BatchTaskRun"("runtimeEnvironmentId", "idempotencyKey"); \ No newline at end of file From 4070c6a31bcee44dc32efd79c3ccb2e04e86ecc9 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 27 Nov 2024 17:51:18 +0000 Subject: [PATCH 31/44] Fixed the name of the idempotencyKeyExpiresAt option and now default idempotency key TTL is 30 days, not 24 hours --- apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts | 2 +- apps/webapp/app/routes/api.v1.tasks.batch.ts | 5 +++-- apps/webapp/app/v3/services/triggerTask.server.ts | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index bae4c10cb0..f45e49ad10 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -74,7 +74,7 @@ const { action, loader } = createActionApiRoute( const run = await service.call(params.taskId, authentication.environment, body, { idempotencyKey: idempotencyKey ?? undefined, - idempoencyKeyExpiresAt: idempotencyKeyExpiresAt, + idempotencyKeyExpiresAt: idempotencyKeyExpiresAt, triggerVersion: triggerVersion ?? undefined, traceContext, spanParentAsLink: spanParentAsLink === 1, diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index 782973d981..a7f1c4ec95 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -72,9 +72,10 @@ const { action, loader } = createActionApiRoute( ? { traceparent, tracestate } : undefined; - // By default, the idempotency key expires in 24 hours + // By default, the idempotency key expires in 30 days const idempotencyKeyExpiresAt = - resolveIdempotencyKeyTTL(idempotencyKeyTTL) ?? new Date(Date.now() + 24 * 60 * 60 * 1000); + resolveIdempotencyKeyTTL(idempotencyKeyTTL) ?? + new Date(Date.now() + 24 * 60 * 60 * 1000 * 30); const service = new BatchTriggerV2Service(); diff --git a/apps/webapp/app/v3/services/triggerTask.server.ts b/apps/webapp/app/v3/services/triggerTask.server.ts index 26a1713e5d..6501e1a717 100644 --- a/apps/webapp/app/v3/services/triggerTask.server.ts +++ b/apps/webapp/app/v3/services/triggerTask.server.ts @@ -29,7 +29,7 @@ import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; export type TriggerTaskServiceOptions = { idempotencyKey?: string; - idempoencyKeyExpiresAt?: Date; + idempotencyKeyExpiresAt?: Date; triggerVersion?: string; traceContext?: Record; spanParentAsLink?: boolean; @@ -59,9 +59,9 @@ export class TriggerTaskService extends BaseService { // TODO: Add idempotency key expiring here const idempotencyKey = options.idempotencyKey ?? body.options?.idempotencyKey; const idempotencyKeyExpiresAt = - options.idempoencyKeyExpiresAt ?? + options.idempotencyKeyExpiresAt ?? resolveIdempotencyKeyTTL(body.options?.idempotencyKeyTTL) ?? - new Date(Date.now() + 24 * 60 * 60 * 1000); + new Date(Date.now() + 24 * 60 * 60 * 1000 * 30); // 30 days const delayUntil = await parseDelay(body.options?.delay); From f97d55ecbd066b7e58d9456c917ad31773552fa0 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 09:20:38 -0800 Subject: [PATCH 32/44] Timezone fix: wrong month in Usage page dropdown --- .../app/components/primitives/DateField.tsx | 26 ++++++++++++++----- .../route.tsx | 1 + 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index 9067b82246..056862d1d3 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -53,7 +53,7 @@ export function DateField({ variant = "small", }: DateFieldProps) { const [value, setValue] = useState( - utcDateToCalendarDate(defaultValue) + dateToCalendarDate(defaultValue) ); const state = useDateFieldState({ @@ -61,11 +61,12 @@ export function DateField({ onChange: (value) => { if (value) { setValue(value); - onValueChange?.(value.toDate("utc")); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + onValueChange?.(value.toDate(timeZone)); } }, - minValue: utcDateToCalendarDate(minValue), - maxValue: utcDateToCalendarDate(maxValue), + minValue: dateToCalendarDate(minValue), + maxValue: dateToCalendarDate(maxValue), shouldForceLeadingZeros: true, granularity, locale: "en-US", @@ -78,7 +79,7 @@ export function DateField({ useEffect(() => { if (state.value === undefined && defaultValue === undefined) return; - const calendarDate = utcDateToCalendarDate(defaultValue); + const calendarDate = dateToCalendarDate(defaultValue); //unchanged if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) { return; @@ -136,7 +137,7 @@ export function DateField({ variant={variants[variant].nowButtonVariant} onClick={() => { const now = new Date(); - setValue(utcDateToCalendarDate(new Date())); + setValue(dateToCalendarDate(new Date())); onValueChange?.(now); }} > @@ -186,6 +187,19 @@ function utcDateToCalendarDate(date?: Date) { : undefined; } +function dateToCalendarDate(date?: Date) { + return date + ? new CalendarDateTime( + date.getFullYear(), + date.getMonth() + 1, + date.getDate(), + date.getHours(), + date.getMinutes(), + date.getSeconds() + ) + : undefined; +} + type DateSegmentProps = { segment: DateSegment; state: DateFieldState; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx index 12fa2db12b..b509a9728f 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.v3.usage/route.tsx @@ -87,6 +87,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { const monthDateFormatter = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", + timeZone: "utc", }); export default function Page() { From 78e3a566a501b4d4456e561147e5aea50128ae16 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 09:37:05 -0800 Subject: [PATCH 33/44] The DateField now defaults to local time, but can be overriden to use utc with an option --- .../app/components/primitives/DateField.tsx | 19 ++++++++++++------- .../app/components/runs/TimeFrameFilter.tsx | 2 ++ .../app/components/runs/v3/BatchFilters.tsx | 8 ++++---- .../app/components/runs/v3/RunFilters.tsx | 8 ++++---- .../route.tsx | 2 ++ 5 files changed, 24 insertions(+), 15 deletions(-) diff --git a/apps/webapp/app/components/primitives/DateField.tsx b/apps/webapp/app/components/primitives/DateField.tsx index 056862d1d3..707f672abe 100644 --- a/apps/webapp/app/components/primitives/DateField.tsx +++ b/apps/webapp/app/components/primitives/DateField.tsx @@ -35,9 +35,12 @@ type DateFieldProps = { showNowButton?: boolean; showClearButton?: boolean; onValueChange?: (value: Date | undefined) => void; + utc?: boolean; variant?: Variant; }; +const deviceTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + export function DateField({ label, defaultValue, @@ -50,10 +53,11 @@ export function DateField({ showGuide = false, showNowButton = false, showClearButton = false, + utc = false, variant = "small", }: DateFieldProps) { const [value, setValue] = useState( - dateToCalendarDate(defaultValue) + utc ? utcDateToCalendarDate(defaultValue) : dateToCalendarDate(defaultValue) ); const state = useDateFieldState({ @@ -61,12 +65,11 @@ export function DateField({ onChange: (value) => { if (value) { setValue(value); - const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; - onValueChange?.(value.toDate(timeZone)); + onValueChange?.(value.toDate(utc ? "utc" : deviceTimezone)); } }, - minValue: dateToCalendarDate(minValue), - maxValue: dateToCalendarDate(maxValue), + minValue: utc ? utcDateToCalendarDate(minValue) : dateToCalendarDate(minValue), + maxValue: utc ? utcDateToCalendarDate(maxValue) : dateToCalendarDate(maxValue), shouldForceLeadingZeros: true, granularity, locale: "en-US", @@ -79,7 +82,9 @@ export function DateField({ useEffect(() => { if (state.value === undefined && defaultValue === undefined) return; - const calendarDate = dateToCalendarDate(defaultValue); + const calendarDate = utc + ? utcDateToCalendarDate(defaultValue) + : dateToCalendarDate(defaultValue); //unchanged if (state.value?.toDate("utc").getTime() === defaultValue?.getTime()) { return; @@ -137,7 +142,7 @@ export function DateField({ variant={variants[variant].nowButtonVariant} onClick={() => { const now = new Date(); - setValue(dateToCalendarDate(new Date())); + setValue(utc ? utcDateToCalendarDate(now) : dateToCalendarDate(now)); onValueChange?.(now); }} > diff --git a/apps/webapp/app/components/runs/TimeFrameFilter.tsx b/apps/webapp/app/components/runs/TimeFrameFilter.tsx index 0255dfc8a3..87e6fe0f95 100644 --- a/apps/webapp/app/components/runs/TimeFrameFilter.tsx +++ b/apps/webapp/app/components/runs/TimeFrameFilter.tsx @@ -214,6 +214,7 @@ export function AbsoluteTimeFrame({ granularity="second" showNowButton showClearButton + utc />
@@ -227,6 +228,7 @@ export function AbsoluteTimeFrame({ granularity="second" showNowButton showClearButton + utc />
diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index 677cbf75c4..14d238388c 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -768,13 +768,13 @@ function AppliedCustomDateRangeFilter() { <> {rangeType === "range" ? ( - –{" "} - + –{" "} + ) : rangeType === "from" ? ( - + ) : ( - + )} } diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index ea8814acce..05ebdf2d48 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -1106,13 +1106,13 @@ function AppliedCustomDateRangeFilter() { <> {rangeType === "range" ? ( - –{" "} - + –{" "} + ) : rangeType === "from" ? ( - + ) : ( - + )} } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx index 079ce897d7..15a911b705 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test.tasks.$taskParam/route.tsx @@ -412,6 +412,7 @@ function ScheduledTaskForm({ granularity="second" showNowButton variant="medium" + utc /> This is the timestamp of the CRON, it will come through to your run in the @@ -436,6 +437,7 @@ function ScheduledTaskForm({ showNowButton showClearButton variant="medium" + utc /> This is the timestamp of the previous run. You can use this in your code to find From f95569a683adf3ecf713d2a6df9d2363607ffc0a Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 17:55:30 +0000 Subject: [PATCH 34/44] =?UTF-8?q?Don=E2=80=99t=20allow=20the=20task=20icon?= =?UTF-8?q?=20to=20get=20squished?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/runs/v3/RunFilters.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 05ebdf2d48..c059b2c16f 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -562,7 +562,9 @@ function TasksDropdown({ } + icon={ + + } > {item.slug} From b7599ed692c753c2d5e3aea6d2aabed9424eafe5 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:05:46 +0000 Subject: [PATCH 35/44] BatchFilters removed unused imports --- .../app/components/runs/v3/BatchFilters.tsx | 38 ++----------------- 1 file changed, 4 insertions(+), 34 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index 14d238388c..fe3fd02f54 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -1,28 +1,11 @@ import * as Ariakit from "@ariakit/react"; -import { - CalendarIcon, - ClockIcon, - CpuChipIcon, - FingerPrintIcon, - Squares2X2Icon, - TagIcon, - TrashIcon, -} from "@heroicons/react/20/solid"; -import { ListChecks } from "lucide-react"; -import { Form, useFetcher } from "@remix-run/react"; -import type { - BatchTaskRunStatus, - BulkActionType, - RuntimeEnvironment, - TaskRunStatus, - TaskTriggerSource, -} from "@trigger.dev/database"; +import { CalendarIcon, CpuChipIcon, Squares2X2Icon, TrashIcon } from "@heroicons/react/20/solid"; +import { Form } from "@remix-run/react"; +import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-react"; -import { matchSorter } from "match-sorter"; import type { ReactNode } from "react"; -import { startTransition, useCallback, useEffect, useMemo, useState } from "react"; +import { startTransition, useCallback, useMemo, useState } from "react"; import { z } from "zod"; -import { TaskIcon } from "~/assets/icons/TaskIcon"; import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; import { DateField } from "~/components/primitives/DateField"; @@ -42,8 +25,6 @@ import { SelectTrigger, shortcutFromIndex, } from "~/components/primitives/Select"; -import { Spinner } from "~/components/primitives/Spinner"; -import { Switch } from "~/components/primitives/Switch"; import { Tooltip, TooltipContent, @@ -51,19 +32,8 @@ import { TooltipTrigger, } from "~/components/primitives/Tooltip"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; -import { useProject } from "~/hooks/useProject"; import { useSearchParams } from "~/hooks/useSearchParam"; -import { type loader as tagsLoader } from "~/routes/resources.projects.$projectParam.runs.tags"; import { Button } from "../../primitives/Buttons"; -import { BulkActionStatusCombo } from "./BulkAction"; -import { - TaskRunStatusCombo, - allTaskRunStatuses, - descriptionForTaskRunStatus, - filterableTaskRunStatuses, - runStatusTitle, -} from "./TaskRunStatus"; -import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; import { allBatchStatuses, BatchStatusCombo, From 99a313738ce28042b15e4f36eed3c94206c2b69c Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:17:10 +0000 Subject: [PATCH 36/44] In the batch filtering, use `id` instead of `batchId` in the URL --- .../app/components/runs/v3/BatchFilters.tsx | 10 ++-- .../route.tsx | 47 +++---------------- apps/webapp/app/utils/pathBuilder.ts | 2 +- 3 files changed, 13 insertions(+), 46 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index fe3fd02f54..7b5de7b82e 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -773,7 +773,7 @@ function BatchIdDropdown({ }) { const [open, setOpen] = useState(); const { value, replace } = useSearchParams(); - const batchIdValue = value("batchId"); + const batchIdValue = value("id"); const [batchId, setBatchId] = useState(batchIdValue); @@ -782,7 +782,7 @@ function BatchIdDropdown({ replace({ cursor: undefined, direction: undefined, - batchId: batchId === "" ? undefined : batchId?.toString(), + id: batchId === "" ? undefined : batchId?.toString(), }); setOpen(false); @@ -851,11 +851,11 @@ function BatchIdDropdown({ function AppliedBatchIdFilter() { const { value, del } = useSearchParams(); - if (value("batchId") === undefined) { + if (value("id") === undefined) { return null; } - const batchId = value("batchId"); + const batchId = value("id"); return ( @@ -866,7 +866,7 @@ function AppliedBatchIdFilter() { del(["batchId", "cursor", "direction"])} + onRemove={() => del(["id", "cursor", "direction"])} /> } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx index 9031f4ba8a..d0b72b2043 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.batches/route.tsx @@ -1,40 +1,23 @@ -import { - CheckCircleIcon, - ClockIcon, - ExclamationCircleIcon, - RectangleGroupIcon, -} from "@heroicons/react/20/solid"; -import { ArrowUpCircleIcon } from "@heroicons/react/24/outline"; +import { ExclamationCircleIcon } from "@heroicons/react/20/solid"; import { BookOpenIcon } from "@heroicons/react/24/solid"; -import { Outlet, useLocation, useNavigation, useParams } from "@remix-run/react"; +import { useNavigation } from "@remix-run/react"; import { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; -import { Feedback } from "~/components/Feedback"; import { ListPagination } from "~/components/ListPagination"; import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; -import { InlineCode } from "~/components/code/InlineCode"; -import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel"; -import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout"; -import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { LinkButton } from "~/components/primitives/Buttons"; import { DateTime } from "~/components/primitives/DateTime"; -import { Header3 } from "~/components/primitives/Headers"; -import { InfoPanel } from "~/components/primitives/InfoPanel"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; -import { PaginationControls } from "~/components/primitives/Pagination"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, -} from "~/components/primitives/Resizable"; import { Spinner } from "~/components/primitives/Spinner"; import { Table, TableBlankRow, TableBody, TableCell, - TableCellChevron, TableHeader, TableHeaderCell, TableRow, @@ -46,30 +29,14 @@ import { BatchStatusCombo, descriptionForBatchStatus, } from "~/components/runs/v3/BatchStatus"; -import { EnabledStatus } from "~/components/runs/v3/EnabledStatus"; import { LiveTimer } from "~/components/runs/v3/LiveTimer"; -import { ScheduleFilters } from "~/components/runs/v3/ScheduleFilters"; -import { - ScheduleTypeCombo, - ScheduleTypeIcon, - scheduleTypeName, -} from "~/components/runs/v3/ScheduleType"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; -import { useUser } from "~/hooks/useUser"; import { redirectWithErrorMessage } from "~/models/message.server"; import { findProjectBySlug } from "~/models/project.server"; import { BatchList, BatchListPresenter } from "~/presenters/v3/BatchListPresenter.server"; -import { type ScheduleListItem } from "~/presenters/v3/ScheduleListPresenter.server"; import { requireUserId } from "~/services/session.server"; -import { - ProjectParamSchema, - docsPath, - v3BatchRunsPath, - v3BillingPath, - v3NewSchedulePath, - v3SchedulePath, -} from "~/utils/pathBuilder"; +import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder"; export const loader = async ({ request, params }: LoaderFunctionArgs) => { const userId = await requireUserId(request); @@ -84,7 +51,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { period: url.searchParams.get("period") ?? undefined, from: url.searchParams.get("from") ?? undefined, to: url.searchParams.get("to") ?? undefined, - id: url.searchParams.get("batchId") ?? undefined, + id: url.searchParams.get("id") ?? undefined, }; const filters = BatchListFilters.parse(s); diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 262dbfbd64..cdc213bfcf 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -446,7 +446,7 @@ export function v3BatchPath( project: ProjectForPath, batch: { friendlyId: string } ) { - return `${v3ProjectPath(organization, project)}/batches?batchId=${batch.friendlyId}`; + return `${v3ProjectPath(organization, project)}/batches?id=${batch.friendlyId}`; } export function v3BatchRunsPath( From 681e75a0486da0ea1325a631e754e0a496f04891 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:19:03 +0000 Subject: [PATCH 37/44] =?UTF-8?q?BatchFilters:=20we=20don=E2=80=99t=20need?= =?UTF-8?q?=20a=20child=20tasks=20hidden=20input=20field?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/webapp/app/components/runs/v3/BatchFilters.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index 7b5de7b82e..ff05799d72 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -88,13 +88,6 @@ export function BatchFilters(props: BatchFiltersProps) { {hasFilters && (
- {searchParams.has("showChildTasks") && ( - - )} From bc07b52750d5ff730fa7ee38f230c6d58f76bc62 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:36:57 +0000 Subject: [PATCH 38/44] Creates some common filter components/functions --- .../app/components/runs/v3/BatchFilters.tsx | 472 +---------------- .../app/components/runs/v3/RunFilters.tsx | 476 +---------------- .../app/components/runs/v3/SharedFilters.tsx | 482 ++++++++++++++++++ 3 files changed, 506 insertions(+), 924 deletions(-) create mode 100644 apps/webapp/app/components/runs/v3/SharedFilters.tsx diff --git a/apps/webapp/app/components/runs/v3/BatchFilters.tsx b/apps/webapp/app/components/runs/v3/BatchFilters.tsx index ff05799d72..02474fa46f 100644 --- a/apps/webapp/app/components/runs/v3/BatchFilters.tsx +++ b/apps/webapp/app/components/runs/v3/BatchFilters.tsx @@ -4,19 +4,15 @@ import { Form } from "@remix-run/react"; import type { BatchTaskRunStatus, RuntimeEnvironment } from "@trigger.dev/database"; import { ListFilterIcon } from "lucide-react"; import type { ReactNode } from "react"; -import { startTransition, useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { z } from "zod"; -import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; -import { DateField } from "~/components/primitives/DateField"; -import { DateTime } from "~/components/primitives/DateTime"; import { FormError } from "~/components/primitives/FormError"; import { Input } from "~/components/primitives/Input"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { ComboBox, - ComboboxProvider, SelectButtonItem, SelectItem, SelectList, @@ -40,6 +36,16 @@ import { batchStatusTitle, descriptionForBatchStatus, } from "./BatchStatus"; +import { + AppliedCustomDateRangeFilter, + AppliedEnvironmentFilter, + AppliedPeriodFilter, + appliedSummary, + CreatedAtDropdown, + CustomDateRangeDropdown, + EnvironmentsDropdown, + FilterMenuProvider, +} from "./SharedFilters"; export const BatchStatus = z.enum(allBatchStatuses); @@ -151,34 +157,6 @@ function FilterMenu(props: BatchFiltersProps) { ); } -function FilterMenuProvider({ - children, - onClose, -}: { - children: (search: string, setSearch: (value: string) => void) => React.ReactNode; - onClose?: () => void; -}) { - const [searchValue, setSearchValue] = useState(""); - - return ( - { - startTransition(() => { - setSearchValue(value); - }); - }} - setOpen={(open) => { - if (!open && onClose) { - onClose(); - } - }} - > - {children(searchValue, setSearchValue)} - - ); -} - function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) { return ( <> @@ -349,410 +327,6 @@ function AppliedStatusFilter() { ); } -function EnvironmentsDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, - possibleEnvironments, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; - possibleEnvironments: DisplayableEnvironment[]; -}) { - const { values, replace } = useSearchParams(); - - const handleChange = (values: string[]) => { - clearSearchValue(); - replace({ environments: values, cursor: undefined, direction: undefined }); - }; - - const filtered = useMemo(() => { - return possibleEnvironments.filter((item) => { - const title = environmentTitle(item, item.userName); - return title.toLowerCase().includes(searchValue.toLowerCase()); - }); - }, [searchValue, possibleEnvironments]); - - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - > - - - {filtered.map((item, index) => ( - - - - ))} - - - - ); -} - -function AppliedEnvironmentFilter({ - possibleEnvironments, -}: Pick) { - const { values, del } = useSearchParams(); - - if (values("environments").length === 0) { - return null; - } - - return ( - - {(search, setSearch) => ( - }> - { - const environment = possibleEnvironments.find((env) => env.id === v); - return environment ? environmentTitle(environment, environment.userName) : v; - }) - )} - onRemove={() => del(["environments", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - possibleEnvironments={possibleEnvironments} - /> - )} - - ); -} - -const timePeriods = [ - { - label: "Last 5 mins", - value: "5m", - }, - { - label: "Last 30 mins", - value: "30m", - }, - { - label: "Last 1 hour", - value: "1h", - }, - { - label: "Last 6 hours", - value: "6h", - }, - { - label: "Last 1 day", - value: "1d", - }, - { - label: "Last 3 days", - value: "3d", - }, - { - label: "Last 7 days", - value: "7d", - }, - { - label: "Last 14 days", - value: "14d", - }, - { - label: "Last 30 days", - value: "30d", - }, - { - label: "All periods", - value: "all", - }, -]; - -function CreatedAtDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, - setFilterType, - hideCustomRange, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; - setFilterType?: (type: FilterType | undefined) => void; - hideCustomRange?: boolean; -}) { - const { value, replace } = useSearchParams(); - - const from = value("from"); - const to = value("to"); - const period = value("period"); - - const handleChange = (newValue: string) => { - clearSearchValue(); - if (newValue === "all") { - if (!period && !from && !to) return; - - replace({ - period: undefined, - from: undefined, - to: undefined, - cursor: undefined, - direction: undefined, - }); - return; - } - - if (newValue === "custom") { - setFilterType?.("daterange"); - return; - } - - replace({ - period: newValue, - from: undefined, - to: undefined, - cursor: undefined, - direction: undefined, - }); - }; - - const filtered = useMemo(() => { - return timePeriods.filter((item) => - item.label.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); - - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - > - - - {filtered.map((item) => ( - - {item.label} - - ))} - {!hideCustomRange ? ( - - Custom date range - - ) : null} - - - - ); -} - -function AppliedPeriodFilter() { - const { value, del } = useSearchParams(); - - if (value("period") === undefined || value("period") === "all") { - return null; - } - - return ( - - {(search, setSearch) => ( - }> - t.value === value("period"))?.label ?? value("period") - } - onRemove={() => del(["period", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - hideCustomRange - /> - )} - - ); -} - -function CustomDateRangeDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const fromSearch = dateFromString(value("from")); - const toSearch = dateFromString(value("to")); - const [from, setFrom] = useState(fromSearch); - const [to, setTo] = useState(toSearch); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - period: undefined, - cursor: undefined, - direction: undefined, - from: from?.getTime().toString(), - to: to?.getTime().toString(), - }); - - setOpen(false); - }, [from, to, replace]); - - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - > -
-
- - -
-
- - -
-
- - -
-
-
-
- ); -} - -function AppliedCustomDateRangeFilter() { - const { value, del } = useSearchParams(); - - if (value("from") === undefined && value("to") === undefined) { - return null; - } - - const fromDate = dateFromString(value("from")); - const toDate = dateFromString(value("to")); - - const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to"; - - return ( - - {(search, setSearch) => ( - }> - - {rangeType === "range" ? ( - - –{" "} - - - ) : rangeType === "from" ? ( - - ) : ( - - )} - - } - onRemove={() => del(["period", "from", "to", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - /> - )} - - ); -} - function BatchIdDropdown({ trigger, clearSearchValue, @@ -870,27 +444,3 @@ function AppliedBatchIdFilter() {
); } - -function dateFromString(value: string | undefined | null): Date | undefined { - if (!value) return; - - //is it an int? - const int = parseInt(value); - if (!isNaN(int)) { - return new Date(int); - } - - return new Date(value); -} - -function appliedSummary(values: string[], maxValues = 3) { - if (values.length === 0) { - return null; - } - - if (values.length > maxValues) { - return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`; - } - - return values.join(", "); -} diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index c059b2c16f..830b679258 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -8,7 +8,6 @@ import { TagIcon, TrashIcon, } from "@heroicons/react/20/solid"; -import { ListChecks } from "lucide-react"; import { Form, useFetcher } from "@remix-run/react"; import type { BulkActionType, @@ -16,15 +15,13 @@ import type { TaskRunStatus, TaskTriggerSource, } from "@trigger.dev/database"; -import { ListFilterIcon } from "lucide-react"; +import { ListChecks, ListFilterIcon } from "lucide-react"; import { matchSorter } from "match-sorter"; import type { ReactNode } from "react"; -import { startTransition, useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { z } from "zod"; import { TaskIcon } from "~/assets/icons/TaskIcon"; -import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; import { AppliedFilter } from "~/components/primitives/AppliedFilter"; -import { DateField } from "~/components/primitives/DateField"; import { DateTime } from "~/components/primitives/DateTime"; import { FormError } from "~/components/primitives/FormError"; import { Input } from "~/components/primitives/Input"; @@ -32,7 +29,6 @@ import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; import { ComboBox, - ComboboxProvider, SelectButtonItem, SelectItem, SelectList, @@ -56,11 +52,21 @@ import { type loader as tagsLoader } from "~/routes/resources.projects.$projectP import { Button } from "../../primitives/Buttons"; import { BulkActionStatusCombo } from "./BulkAction"; import { - TaskRunStatusCombo, + AppliedCustomDateRangeFilter, + AppliedEnvironmentFilter, + AppliedPeriodFilter, + appliedSummary, + CreatedAtDropdown, + CustomDateRangeDropdown, + EnvironmentsDropdown, + FilterMenuProvider, +} from "./SharedFilters"; +import { allTaskRunStatuses, descriptionForTaskRunStatus, filterableTaskRunStatuses, runStatusTitle, + TaskRunStatusCombo, } from "./TaskRunStatus"; import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; @@ -211,34 +217,6 @@ function FilterMenu(props: RunFiltersProps) { ); } -function FilterMenuProvider({ - children, - onClose, -}: { - children: (search: string, setSearch: (value: string) => void) => React.ReactNode; - onClose?: () => void; -}) { - const [searchValue, setSearchValue] = useState(""); - - return ( - { - startTransition(() => { - setSearchValue(value); - }); - }} - setOpen={(open) => { - if (!open && onClose) { - onClose(); - } - }} - > - {children(searchValue, setSearchValue)} - - ); -} - function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) { return ( <> @@ -422,100 +400,6 @@ function AppliedStatusFilter() { ); } -function EnvironmentsDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, - possibleEnvironments, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; - possibleEnvironments: DisplayableEnvironment[]; -}) { - const { values, replace } = useSearchParams(); - - const handleChange = (values: string[]) => { - clearSearchValue(); - replace({ environments: values, cursor: undefined, direction: undefined }); - }; - - const filtered = useMemo(() => { - return possibleEnvironments.filter((item) => { - const title = environmentTitle(item, item.userName); - return title.toLowerCase().includes(searchValue.toLowerCase()); - }); - }, [searchValue, possibleEnvironments]); - - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - > - - - {filtered.map((item, index) => ( - - - - ))} - - - - ); -} - -function AppliedEnvironmentFilter({ - possibleEnvironments, -}: Pick) { - const { values, del } = useSearchParams(); - - if (values("environments").length === 0) { - return null; - } - - return ( - - {(search, setSearch) => ( - }> - { - const environment = possibleEnvironments.find((env) => env.id === v); - return environment ? environmentTitle(environment, environment.userName) : v; - }) - )} - onRemove={() => del(["environments", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - possibleEnvironments={possibleEnvironments} - /> - )} - - ); -} - function TasksDropdown({ trigger, clearSearchValue, @@ -820,316 +704,6 @@ function AppliedTagsFilter() { ); } -const timePeriods = [ - { - label: "Last 5 mins", - value: "5m", - }, - { - label: "Last 30 mins", - value: "30m", - }, - { - label: "Last 1 hour", - value: "1h", - }, - { - label: "Last 6 hours", - value: "6h", - }, - { - label: "Last 1 day", - value: "1d", - }, - { - label: "Last 3 days", - value: "3d", - }, - { - label: "Last 7 days", - value: "7d", - }, - { - label: "Last 14 days", - value: "14d", - }, - { - label: "Last 30 days", - value: "30d", - }, - { - label: "All periods", - value: "all", - }, -]; - -function CreatedAtDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, - setFilterType, - hideCustomRange, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; - setFilterType?: (type: FilterType | undefined) => void; - hideCustomRange?: boolean; -}) { - const { value, replace } = useSearchParams(); - - const from = value("from"); - const to = value("to"); - const period = value("period"); - - const handleChange = (newValue: string) => { - clearSearchValue(); - if (newValue === "all") { - if (!period && !from && !to) return; - - replace({ - period: undefined, - from: undefined, - to: undefined, - cursor: undefined, - direction: undefined, - }); - return; - } - - if (newValue === "custom") { - setFilterType?.("daterange"); - return; - } - - replace({ - period: newValue, - from: undefined, - to: undefined, - cursor: undefined, - direction: undefined, - }); - }; - - const filtered = useMemo(() => { - return timePeriods.filter((item) => - item.label.toLowerCase().includes(searchValue.toLowerCase()) - ); - }, [searchValue]); - - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - > - - - {filtered.map((item) => ( - - {item.label} - - ))} - {!hideCustomRange ? ( - - Custom date range - - ) : null} - - - - ); -} - -function AppliedPeriodFilter() { - const { value, del } = useSearchParams(); - - if (value("period") === undefined || value("period") === "all") { - return null; - } - - return ( - - {(search, setSearch) => ( - }> - t.value === value("period"))?.label ?? value("period") - } - onRemove={() => del(["period", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - hideCustomRange - /> - )} - - ); -} - -function CustomDateRangeDropdown({ - trigger, - clearSearchValue, - searchValue, - onClose, -}: { - trigger: ReactNode; - clearSearchValue: () => void; - searchValue: string; - onClose?: () => void; -}) { - const [open, setOpen] = useState(); - const { value, replace } = useSearchParams(); - const fromSearch = dateFromString(value("from")); - const toSearch = dateFromString(value("to")); - const [from, setFrom] = useState(fromSearch); - const [to, setTo] = useState(toSearch); - - const apply = useCallback(() => { - clearSearchValue(); - replace({ - period: undefined, - cursor: undefined, - direction: undefined, - from: from?.getTime().toString(), - to: to?.getTime().toString(), - }); - - setOpen(false); - }, [from, to, replace]); - - return ( - - {trigger} - { - if (onClose) { - onClose(); - return false; - } - - return true; - }} - > -
-
- - -
-
- - -
-
- - -
-
-
-
- ); -} - -function AppliedCustomDateRangeFilter() { - const { value, del } = useSearchParams(); - - if (value("from") === undefined && value("to") === undefined) { - return null; - } - - const fromDate = dateFromString(value("from")); - const toDate = dateFromString(value("to")); - - const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to"; - - return ( - - {(search, setSearch) => ( - }> - - {rangeType === "range" ? ( - - –{" "} - - - ) : rangeType === "from" ? ( - - ) : ( - - )} - - } - onRemove={() => del(["period", "from", "to", "cursor", "direction"])} - /> - - } - searchValue={search} - clearSearchValue={() => setSearch("")} - /> - )} - - ); -} - function ShowChildTasksToggle() { const { value, replace } = useSearchParams(); @@ -1509,27 +1083,3 @@ function AppliedScheduleIdFilter() { ); } - -function dateFromString(value: string | undefined | null): Date | undefined { - if (!value) return; - - //is it an int? - const int = parseInt(value); - if (!isNaN(int)) { - return new Date(int); - } - - return new Date(value); -} - -function appliedSummary(values: string[], maxValues = 3) { - if (values.length === 0) { - return null; - } - - if (values.length > maxValues) { - return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`; - } - - return values.join(", "); -} diff --git a/apps/webapp/app/components/runs/v3/SharedFilters.tsx b/apps/webapp/app/components/runs/v3/SharedFilters.tsx new file mode 100644 index 0000000000..3f07f133ed --- /dev/null +++ b/apps/webapp/app/components/runs/v3/SharedFilters.tsx @@ -0,0 +1,482 @@ +import * as Ariakit from "@ariakit/react"; +import type { RuntimeEnvironment } from "@trigger.dev/database"; +import type { ReactNode } from "react"; +import { startTransition, useCallback, useMemo, useState } from "react"; +import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { DateField } from "~/components/primitives/DateField"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Label } from "~/components/primitives/Label"; +import { + ComboBox, + ComboboxProvider, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + shortcutFromIndex, +} from "~/components/primitives/Select"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { Button } from "../../primitives/Buttons"; + +export type DisplayableEnvironment = Pick & { + userName?: string; +}; + +export function FilterMenuProvider({ + children, + onClose, +}: { + children: (search: string, setSearch: (value: string) => void) => React.ReactNode; + onClose?: () => void; +}) { + const [searchValue, setSearchValue] = useState(""); + + return ( + { + startTransition(() => { + setSearchValue(value); + }); + }} + setOpen={(open) => { + if (!open && onClose) { + onClose(); + } + }} + > + {children(searchValue, setSearchValue)} + + ); +} + +export function EnvironmentsDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + possibleEnvironments, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + possibleEnvironments: DisplayableEnvironment[]; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ environments: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return possibleEnvironments.filter((item) => { + const title = environmentTitle(item, item.userName); + return title.toLowerCase().includes(searchValue.toLowerCase()); + }); + }, [searchValue, possibleEnvironments]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + ))} + + + + ); +} + +export function AppliedEnvironmentFilter({ + possibleEnvironments, +}: { + possibleEnvironments: DisplayableEnvironment[]; +}) { + const { values, del } = useSearchParams(); + + if (values("environments").length === 0) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + { + const environment = possibleEnvironments.find((env) => env.id === v); + return environment ? environmentTitle(environment, environment.userName) : v; + }) + )} + onRemove={() => del(["environments", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + possibleEnvironments={possibleEnvironments} + /> + )} + + ); +} + +const timePeriods = [ + { + label: "Last 5 mins", + value: "5m", + }, + { + label: "Last 30 mins", + value: "30m", + }, + { + label: "Last 1 hour", + value: "1h", + }, + { + label: "Last 6 hours", + value: "6h", + }, + { + label: "Last 1 day", + value: "1d", + }, + { + label: "Last 3 days", + value: "3d", + }, + { + label: "Last 7 days", + value: "7d", + }, + { + label: "Last 14 days", + value: "14d", + }, + { + label: "Last 30 days", + value: "30d", + }, + { + label: "All periods", + value: "all", + }, +]; + +export function CreatedAtDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, + setFilterType, + hideCustomRange, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; + setFilterType?: (type: "daterange" | undefined) => void; + hideCustomRange?: boolean; +}) { + const { value, replace } = useSearchParams(); + + const from = value("from"); + const to = value("to"); + const period = value("period"); + + const handleChange = (newValue: string) => { + clearSearchValue(); + if (newValue === "all") { + if (!period && !from && !to) return; + + replace({ + period: undefined, + from: undefined, + to: undefined, + cursor: undefined, + direction: undefined, + }); + return; + } + + if (newValue === "custom") { + setFilterType?.("daterange"); + return; + } + + replace({ + period: newValue, + from: undefined, + to: undefined, + cursor: undefined, + direction: undefined, + }); + }; + + const filtered = useMemo(() => { + return timePeriods.filter((item) => + item.label.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > + + + {filtered.map((item) => ( + + {item.label} + + ))} + {!hideCustomRange ? ( + + Custom date range + + ) : null} + + + + ); +} + +export function AppliedPeriodFilter() { + const { value, del } = useSearchParams(); + + if (value("period") === undefined || value("period") === "all") { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + t.value === value("period"))?.label ?? value("period") + } + onRemove={() => del(["period", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + hideCustomRange + /> + )} + + ); +} + +export function CustomDateRangeDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const fromSearch = dateFromString(value("from")); + const toSearch = dateFromString(value("to")); + const [from, setFrom] = useState(fromSearch); + const [to, setTo] = useState(toSearch); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + period: undefined, + cursor: undefined, + direction: undefined, + from: from?.getTime().toString(), + to: to?.getTime().toString(), + }); + + setOpen(false); + }, [from, to, replace]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + + return true; + }} + > +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ ); +} + +export function AppliedCustomDateRangeFilter() { + const { value, del } = useSearchParams(); + + if (value("from") === undefined && value("to") === undefined) { + return null; + } + + const fromDate = dateFromString(value("from")); + const toDate = dateFromString(value("to")); + + const rangeType = fromDate && toDate ? "range" : fromDate ? "from" : "to"; + + return ( + + {(search, setSearch) => ( + }> + + {rangeType === "range" ? ( + + –{" "} + + + ) : rangeType === "from" ? ( + + ) : ( + + )} + + } + onRemove={() => del(["period", "from", "to", "cursor", "direction"])} + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +export function appliedSummary(values: string[], maxValues = 3) { + if (values.length === 0) { + return null; + } + + if (values.length > maxValues) { + return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`; + } + + return values.join(", "); +} + +function dateFromString(value: string | undefined | null): Date | undefined { + if (!value) return; + + //is it an int? + const int = parseInt(value); + if (!isNaN(int)) { + return new Date(int); + } + + return new Date(value); +} From b8229154698b1bd64286d732bf2eb73f7f5d92b2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:37:07 +0000 Subject: [PATCH 39/44] Fix for batchVersion check when filtering by batch status --- apps/webapp/app/presenters/v3/BatchListPresenter.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index 7bb3ea7382..a5eaf40269 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -127,7 +127,7 @@ WHERE statuses && statuses.length > 0 ? Prisma.sql`AND b.status = ANY(ARRAY[${Prisma.join( statuses - )}]::"BatchTaskRunStatus"[]) AND b.version <> 'v1'` + )}]::"BatchTaskRunStatus"[]) AND b."batchVersion" <> 'v1'` : Prisma.empty } ${ From 4c5fd2ab718f2d7b55999a08ae20e206862fb02e Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Wed, 27 Nov 2024 19:55:55 +0000 Subject: [PATCH 40/44] Add additional logging around telemetry and more attributes for trigger spans --- packages/trigger-sdk/src/v3/shared.ts | 146 +++++++++++++-------- references/v3-catalog/src/trigger/batch.ts | 104 ++++++++++----- references/v3-catalog/src/utils/types.ts | 8 ++ references/v3-catalog/trigger.config.ts | 2 +- 4 files changed, 169 insertions(+), 91 deletions(-) create mode 100644 references/v3-catalog/src/utils/types.ts diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index b5904ff527..3d8681c176 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -571,24 +571,26 @@ export async function batchTriggerById( tracer, icon: "trigger", onResponseBody(body, span) { - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "id" in body && - typeof body.id === "string" - ) { - span.setAttribute("batchId", body.id); - } + if (body && typeof body === "object" && !Array.isArray(body)) { + if ("id" in body && typeof body.id === "string") { + span.setAttribute("batchId", body.id); + } + + if ("runs" in body && Array.isArray(body.runs)) { + span.setAttribute("runCount", body.runs.length); + } - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "runs" in body && - Array.isArray(body.runs) - ) { - span.setAttribute("runCount", body.runs.length); + if ("isCached" in body && typeof body.isCached === "boolean") { + if (body.isCached) { + console.warn(`Result is a cached response because the request was idempotent.`); + } + + span.setAttribute("isCached", body.isCached); + } + + if ("idempotencyKey" in body && typeof body.idempotencyKey === "string") { + span.setAttribute("idempotencyKey", body.idempotencyKey); + } } }, ...requestOptions, @@ -660,6 +662,15 @@ export async function batchTriggerByIdAndWait( span.setAttribute("batchId", response.id); span.setAttribute("runCount", response.runs.length); + span.setAttribute("isCached", response.isCached); + + if (response.isCached) { + console.warn(`Result is a cached response because the request was idempotent.`); + } + + if (response.idempotencyKey) { + span.setAttribute("idempotencyKey", response.idempotencyKey); + } const result = await runtime.waitForBatch({ id: response.id, @@ -733,24 +744,26 @@ export async function batchTriggerTasks( tracer, icon: "trigger", onResponseBody(body, span) { - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "id" in body && - typeof body.id === "string" - ) { - span.setAttribute("batchId", body.id); - } + if (body && typeof body === "object" && !Array.isArray(body)) { + if ("id" in body && typeof body.id === "string") { + span.setAttribute("batchId", body.id); + } + + if ("runs" in body && Array.isArray(body.runs)) { + span.setAttribute("runCount", body.runs.length); + } + + if ("isCached" in body && typeof body.isCached === "boolean") { + if (body.isCached) { + console.warn(`Result is a cached response because the request was idempotent.`); + } + + span.setAttribute("isCached", body.isCached); + } - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "runs" in body && - Array.isArray(body.runs) - ) { - span.setAttribute("runCount", body.runs.length); + if ("idempotencyKey" in body && typeof body.idempotencyKey === "string") { + span.setAttribute("idempotencyKey", body.idempotencyKey); + } } }, ...requestOptions, @@ -824,6 +837,15 @@ export async function batchTriggerAndWaitTasks( tracer, icon: "trigger", onResponseBody: (body, span) => { - body && - typeof body === "object" && - !Array.isArray(body) && - "id" in body && - typeof body.id === "string" && - span.setAttribute("runId", body.id); + if (body && typeof body === "object" && !Array.isArray(body)) { + if ("id" in body && typeof body.id === "string") { + span.setAttribute("runId", body.id); + } + } }, ...requestOptions, } @@ -951,24 +972,26 @@ async function batchTrigger_internal( tracer, icon: "trigger", onResponseBody(body, span) { - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "id" in body && - typeof body.id === "string" - ) { - span.setAttribute("batchId", body.id); - } + if (body && typeof body === "object" && !Array.isArray(body)) { + if ("id" in body && typeof body.id === "string") { + span.setAttribute("batchId", body.id); + } - if ( - body && - typeof body === "object" && - !Array.isArray(body) && - "runs" in body && - Array.isArray(body.runs) - ) { - span.setAttribute("runCount", body.runs.length); + if ("runs" in body && Array.isArray(body.runs)) { + span.setAttribute("runCount", body.runs.length); + } + + if ("isCached" in body && typeof body.isCached === "boolean") { + if (body.isCached) { + console.warn(`Result is a cached response because the request was idempotent.`); + } + + span.setAttribute("isCached", body.isCached); + } + + if ("idempotencyKey" in body && typeof body.idempotencyKey === "string") { + span.setAttribute("idempotencyKey", body.idempotencyKey); + } } }, ...requestOptions, @@ -1112,6 +1135,15 @@ async function batchTriggerAndWait_internal = T; -export type ExpectTrue = T; -export type ExpectFalse = T; -export type IsTrue = T; -export type IsFalse = T; - -export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 - ? true - : false; -export type NotEqual = true extends Equal ? false : true; - -// https://stackoverflow.com/questions/49927523/disallow-call-with-any/49928360#49928360 -export type IsAny = 0 extends 1 & T ? true : false; -export type NotAny = true extends IsAny ? false : true; - -export type Debug = { [K in keyof T]: T[K] }; -export type MergeInsertions = T extends object ? { [K in keyof T]: MergeInsertions } : T; - -export type Alike = Equal, MergeInsertions>; - -export type ExpectExtends = EXPECTED extends VALUE ? true : false; -export type ExpectValidArgs< - FUNC extends (...args: any[]) => any, - ARGS extends any[], -> = ARGS extends Parameters ? true : false; - -export type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( - k: infer I -) => void - ? I - : never; - export const allV2TestTask = task({ id: "all-v2-test", retry: { @@ -645,3 +624,62 @@ export const batchV2TestChild = task({ return payload; }, }); + +export const batchAutoIdempotencyKeyTask = task({ + id: "batch-auto-idempotency-key", + retry: { + maxAttempts: 3, + }, + run: async () => { + const idempotencyKey = await idempotencyKeys.create("first-batch-1"); + + logger.debug("Idempotency key", { idempotencyKey }); + + const response1 = await batchAutoIdempotencyKeyChild.batchTrigger( + [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }], + { + idempotencyKey: idempotencyKey, + } + ); + + logger.debug("Response 1", { response1 }); + + const idempotencyKey2 = await idempotencyKeys.create("first-batch-2", { scope: "global" }); + + logger.debug("Idempotency key 2", { idempotencyKey2 }); + + const response2 = await batchAutoIdempotencyKeyChild.batchTrigger( + [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }], + { + idempotencyKey: idempotencyKey2, + } + ); + + logger.debug("Response 2", { response2 }); + + const idempotencyKey3 = await idempotencyKeys.create(randomUUID()); + + logger.debug("Idempotency key 3", { idempotencyKey3 }); + + const response3 = await batchAutoIdempotencyKeyChild.batchTrigger( + [{ payload: { foo: "bar" } }, { payload: { foo: "baz" } }], + { + idempotencyKey: idempotencyKey3, + } + ); + + logger.debug("Response 3", { response3 }); + + throw new Error("Forcing a retry to see if another batch is created"); + }, +}); + +export const batchAutoIdempotencyKeyChild = task({ + id: "batch-auto-idempotency-key-child", + retry: { + maxAttempts: 3, + }, + run: async (payload: any) => { + return payload; + }, +}); diff --git a/references/v3-catalog/src/utils/types.ts b/references/v3-catalog/src/utils/types.ts new file mode 100644 index 0000000000..ff534f0ccd --- /dev/null +++ b/references/v3-catalog/src/utils/types.ts @@ -0,0 +1,8 @@ +export type Expect = T; +export type ExpectTrue = T; +export type ExpectFalse = T; + +export type Equal = (() => T extends X ? 1 : 2) extends () => T extends Y ? 1 : 2 + ? true + : false; +export type NotEqual = true extends Equal ? false : true; diff --git a/references/v3-catalog/trigger.config.ts b/references/v3-catalog/trigger.config.ts index fe6ae1200f..637c9bb597 100644 --- a/references/v3-catalog/trigger.config.ts +++ b/references/v3-catalog/trigger.config.ts @@ -20,7 +20,7 @@ export default defineConfig({ // Set the maxDuration to 300s for all tasks. See https://trigger.dev/docs/runs/max-duration // maxDuration: 300, retries: { - enabledInDev: false, + enabledInDev: true, default: { maxAttempts: 10, minTimeoutInMs: 5_000, From fc821ad93599d0c5bd259c73f8fe318eea431b21 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:46:07 +0000 Subject: [PATCH 41/44] Show clear button for specific id filters --- apps/webapp/app/components/runs/v3/RunFilters.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index 830b679258..7ee7bf02a0 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -133,7 +133,10 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("bulkId") || searchParams.has("tags") || searchParams.has("from") || - searchParams.has("to"); + searchParams.has("to") || + searchParams.has("batchId") || + searchParams.has("runId") || + searchParams.has("scheduleId"); return (
From d5c1bdaa4b2bb1abfbebdd53522e7ca4e0735196 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 18:55:57 +0000 Subject: [PATCH 42/44] Batch list: only allow environments that are part of this project --- apps/webapp/app/presenters/v3/BatchListPresenter.server.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts index a5eaf40269..f3d25269b9 100644 --- a/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/BatchListPresenter.server.ts @@ -81,7 +81,12 @@ export class BatchListPresenter extends BasePresenter { let environmentIds = project.environments.map((e) => e.id); if (environments && environments.length > 0) { - environmentIds = environments; + //if environments are passed in, we only include them if they're in the project + environmentIds = environments.filter((e) => project.environments.some((pe) => pe.id === e)); + } + + if (environmentIds.length === 0) { + throw new Error("No matching environments found for the project"); } const periodMs = period ? parse(period) : undefined; From 69f3dcaa1f3b85bd377ae2197c973a3818714152 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Wed, 27 Nov 2024 19:05:36 +0000 Subject: [PATCH 43/44] Unnecessary optional chain Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/webapp/app/presenters/v3/RunListPresenter.server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts index 899f594b4d..dfbd861f09 100644 --- a/apps/webapp/app/presenters/v3/RunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/RunListPresenter.server.ts @@ -162,7 +162,7 @@ export class RunListPresenter extends BasePresenter { }); if (batch) { - batchId = batch?.id; + batchId = batch.id; } } From 153af67368e74768a63ccc97e47b745cacbc97d8 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Thu, 28 Nov 2024 11:06:14 +0000 Subject: [PATCH 44/44] Add JSDocs --- packages/trigger-sdk/src/v3/batch.ts | 17 ++ packages/trigger-sdk/src/v3/shared.ts | 284 ++++++++++++++++++++++++++ 2 files changed, 301 insertions(+) diff --git a/packages/trigger-sdk/src/v3/batch.ts b/packages/trigger-sdk/src/v3/batch.ts index 9b7a5df4c8..292bf13f32 100644 --- a/packages/trigger-sdk/src/v3/batch.ts +++ b/packages/trigger-sdk/src/v3/batch.ts @@ -22,6 +22,23 @@ export const batch = { retrieve: retrieveBatch, }; +/** + * Retrieves details about a specific batch by its ID. + * + * @param {string} batchId - The unique identifier of the batch to retrieve + * @param {ApiRequestOptions} [requestOptions] - Optional API request configuration options + * @returns {ApiPromise} A promise that resolves with the batch details + * + * @example + * // First trigger a batch + * const response = await batch.trigger([ + * { id: "simple-task", payload: { message: "Hello, World!" } } + * ]); + * + * // Then retrieve the batch details + * const batchDetails = await batch.retrieve(response.batchId); + * console.log("batch", batchDetails); + */ function retrieveBatch( batchId: string, requestOptions?: ApiRequestOptions diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index 3d8681c176..cdf90eaac4 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -520,6 +520,59 @@ export async function batchTrigger( ); } +/** + * Triggers multiple runs of different tasks with specified payloads and options. + * + * @template TTask - The type of task(s) to be triggered, extends AnyTask + * + * @param {Array>>} items - Array of task items to trigger + * @param {BatchTriggerOptions} [options] - Optional batch-level trigger options + * @param {TriggerApiRequestOptions} [requestOptions] - Optional API request configuration + * + * @returns {Promise>>} A promise that resolves with the batch run handle + * containing batch ID, cached status, idempotency info, runs, and public access token + * + * @example + * ```ts + * import { batch } from "@trigger.dev/sdk/v3"; + * import type { myTask1, myTask2 } from "~/trigger/myTasks"; + * + * // Trigger multiple tasks with different payloads + * const result = await batch.trigger([ + * { + * id: "my-task-1", + * payload: { some: "data" }, + * options: { + * queue: "default", + * concurrencyKey: "key", + * idempotencyKey: "unique-key", + * delay: "5m", + * tags: ["tag1", "tag2"] + * } + * }, + * { + * id: "my-task-2", + * payload: { other: "data" } + * } + * ]); + * ``` + * + * @description + * Each task item in the array can include: + * - `id`: The unique identifier of the task + * - `payload`: The data to pass to the task + * - `options`: Optional task-specific settings including: + * - `queue`: Specify a queue for the task + * - `concurrencyKey`: Control concurrent execution + * - `idempotencyKey`: Prevent duplicate runs + * - `idempotencyKeyTTL`: Time-to-live for idempotency key + * - `delay`: Delay before task execution + * - `ttl`: Time-to-live for the task + * - `tags`: Array of tags for the task + * - `maxAttempts`: Maximum retry attempts + * - `metadata`: Additional metadata + * - `maxDuration`: Maximum execution duration + */ export async function batchTriggerById( items: Array>>, options?: BatchTriggerOptions, @@ -608,6 +661,83 @@ export async function batchTriggerById( return handle as BatchRunHandleFromTypes>; } +/** + * Triggers multiple tasks and waits for all of them to complete before returning their results. + * This function must be called from within a task.run() context. + * + * @template TTask - Union type of tasks to be triggered, extends AnyTask + * + * @param {Array>>} items - Array of task items to trigger + * @param {TriggerApiRequestOptions} [requestOptions] - Optional API request configuration + * + * @returns {Promise>} A promise that resolves with the batch results, including + * success/failure status and strongly-typed outputs for each task + * + * @throws {Error} If called outside of a task.run() context + * @throws {Error} If no API client is configured + * + * @example + * ```ts + * import { batch, task } from "@trigger.dev/sdk/v3"; + * + * export const parentTask = task({ + * id: "parent-task", + * run: async (payload: string) => { + * const results = await batch.triggerAndWait([ + * { + * id: "child-task-1", + * payload: { foo: "World" }, + * options: { + * queue: "default", + * delay: "5m", + * tags: ["batch", "child1"] + * } + * }, + * { + * id: "child-task-2", + * payload: { bar: 42 } + * } + * ]); + * + * // Type-safe result handling + * for (const result of results) { + * if (result.ok) { + * switch (result.taskIdentifier) { + * case "child-task-1": + * console.log("Child task 1 output:", result.output); // string type + * break; + * case "child-task-2": + * console.log("Child task 2 output:", result.output); // number type + * break; + * } + * } else { + * console.error("Task failed:", result.error); + * } + * } + * } + * }); + * ``` + * + * @description + * Each task item in the array can include: + * - `id`: The task identifier (must match one of the tasks in the union type) + * - `payload`: Strongly-typed payload matching the task's input type + * - `options`: Optional task-specific settings including: + * - `queue`: Specify a queue for the task + * - `concurrencyKey`: Control concurrent execution + * - `delay`: Delay before task execution + * - `ttl`: Time-to-live for the task + * - `tags`: Array of tags for the task + * - `maxAttempts`: Maximum retry attempts + * - `metadata`: Additional metadata + * - `maxDuration`: Maximum execution duration + * + * The function provides full type safety for: + * - Task IDs + * - Payload types + * - Return value types + * - Error handling + */ export async function batchTriggerByIdAndWait( items: Array>>, requestOptions?: TriggerApiRequestOptions @@ -691,6 +821,83 @@ export async function batchTriggerByIdAndWait( ); } +/** + * Triggers multiple tasks and waits for all of them to complete before returning their results. + * This function must be called from within a task.run() context. + * + * @template TTask - Union type of tasks to be triggered, extends AnyTask + * + * @param {Array>>} items - Array of task items to trigger + * @param {TriggerApiRequestOptions} [requestOptions] - Optional API request configuration + * + * @returns {Promise>} A promise that resolves with the batch results, including + * success/failure status and strongly-typed outputs for each task + * + * @throws {Error} If called outside of a task.run() context + * @throws {Error} If no API client is configured + * + * @example + * ```ts + * import { batch, task } from "@trigger.dev/sdk/v3"; + * + * export const parentTask = task({ + * id: "parent-task", + * run: async (payload: string) => { + * const results = await batch.triggerAndWait([ + * { + * id: "child-task-1", + * payload: { foo: "World" }, + * options: { + * queue: "default", + * delay: "5m", + * tags: ["batch", "child1"] + * } + * }, + * { + * id: "child-task-2", + * payload: { bar: 42 } + * } + * ]); + * + * // Type-safe result handling + * for (const result of results) { + * if (result.ok) { + * switch (result.taskIdentifier) { + * case "child-task-1": + * console.log("Child task 1 output:", result.output); // string type + * break; + * case "child-task-2": + * console.log("Child task 2 output:", result.output); // number type + * break; + * } + * } else { + * console.error("Task failed:", result.error); + * } + * } + * } + * }); + * ``` + * + * @description + * Each task item in the array can include: + * - `id`: The task identifier (must match one of the tasks in the union type) + * - `payload`: Strongly-typed payload matching the task's input type + * - `options`: Optional task-specific settings including: + * - `queue`: Specify a queue for the task + * - `concurrencyKey`: Control concurrent execution + * - `delay`: Delay before task execution + * - `ttl`: Time-to-live for the task + * - `tags`: Array of tags for the task + * - `maxAttempts`: Maximum retry attempts + * - `metadata`: Additional metadata + * - `maxDuration`: Maximum execution duration + * + * The function provides full type safety for: + * - Task IDs + * - Payload types + * - Return value types + * - Error handling + */ export async function batchTriggerTasks( items: { [K in keyof TTasks]: BatchByTaskItem; @@ -781,6 +988,83 @@ export async function batchTriggerTasks( return handle as unknown as BatchTasksRunHandleFromTypes; } +/** + * Triggers multiple tasks and waits for all of them to complete before returning their results. + * This function must be called from within a task.run() context. + * + * @template TTask - Union type of tasks to be triggered, extends AnyTask + * + * @param {Array>>} items - Array of task items to trigger + * @param {TriggerApiRequestOptions} [requestOptions] - Optional API request configuration + * + * @returns {Promise>} A promise that resolves with the batch results, including + * success/failure status and strongly-typed outputs for each task + * + * @throws {Error} If called outside of a task.run() context + * @throws {Error} If no API client is configured + * + * @example + * ```ts + * import { batch, task } from "@trigger.dev/sdk/v3"; + * + * export const parentTask = task({ + * id: "parent-task", + * run: async (payload: string) => { + * const results = await batch.triggerAndWait([ + * { + * id: "child-task-1", + * payload: { foo: "World" }, + * options: { + * queue: "default", + * delay: "5m", + * tags: ["batch", "child1"] + * } + * }, + * { + * id: "child-task-2", + * payload: { bar: 42 } + * } + * ]); + * + * // Type-safe result handling + * for (const result of results) { + * if (result.ok) { + * switch (result.taskIdentifier) { + * case "child-task-1": + * console.log("Child task 1 output:", result.output); // string type + * break; + * case "child-task-2": + * console.log("Child task 2 output:", result.output); // number type + * break; + * } + * } else { + * console.error("Task failed:", result.error); + * } + * } + * } + * }); + * ``` + * + * @description + * Each task item in the array can include: + * - `id`: The task identifier (must match one of the tasks in the union type) + * - `payload`: Strongly-typed payload matching the task's input type + * - `options`: Optional task-specific settings including: + * - `queue`: Specify a queue for the task + * - `concurrencyKey`: Control concurrent execution + * - `delay`: Delay before task execution + * - `ttl`: Time-to-live for the task + * - `tags`: Array of tags for the task + * - `maxAttempts`: Maximum retry attempts + * - `metadata`: Additional metadata + * - `maxDuration`: Maximum execution duration + * + * The function provides full type safety for: + * - Task IDs + * - Payload types + * - Return value types + * - Error handling + */ export async function batchTriggerAndWaitTasks( items: { [K in keyof TTasks]: BatchByTaskAndWaitItem;