import type { BatchTriggerTaskV2RequestBody, BatchTriggerTaskV2Response, IOPacket, } from "@trigger.dev/core/v3"; import { packetRequiresOffloading, parsePacket } from "@trigger.dev/core/v3"; import type { BatchTaskRun, TaskRunAttempt } from "@trigger.dev/database"; import { isPrismaRaceConditionError, isPrismaRetriableError, isUniqueConstraintError, Prisma, } from "@trigger.dev/database"; import type { RunStore } from "@internal/run-store"; import { z } from "zod"; import type { PrismaClientOrTransaction } from "~/db.server"; import { prisma } from "~/db.server"; import { runStore as defaultRunStore } from "~/v3/runStore.server"; import { env } from "~/env.server"; import { findEnvironmentById } from "~/models/runtimeEnvironment.server"; import { batchTaskRunItemStatusForRunStatus } from "~/models/taskRun.server"; import type { AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { dependentAttemptWhere } from "./dependentAttemptScope"; import { logger } from "~/services/logger.server"; import { getEntitlement } from "~/services/platform.v3.server"; import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server"; import { resolveRunIdMintKind, type RunIdMintKind } from "~/v3/engineVersion.server"; import { resolveInheritedMintKind } from "~/v3/runOpsMigration/resolveInheritedMintKind.server"; import { mintFriendlyIdForKind } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server"; import { mintBatchFriendlyId } from "~/v3/runOpsMigration/mintBatchFriendlyId.server"; import { batchTriggerWorker } from "../batchTriggerWorker.server"; import { legacyRunEngineWorker } from "../legacyRunEngineWorker.server"; import { marqs } from "../marqs/index.server"; import { guardQueueSizeLimitsForEnv } from "../queueSizeLimits.server"; import { downloadPacketFromObjectStore, uploadPacketToObjectStore } from "../objectStore.server"; import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus"; import { startActiveSpan } from "../tracer.server"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { ResumeBatchRunService } from "./resumeBatchRun.server"; import { OutOfEntitlementError, TriggerTaskService } from "./triggerTask.server"; const PROCESSING_BATCH_SIZE = 50; const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20; const MAX_ATTEMPTS = 10; export const BatchProcessingStrategy = z.enum(["sequential", "parallel"]); export type BatchProcessingStrategy = z.infer; 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; idempotencyKeyExpiresAt?: Date; triggerVersion?: string; traceContext?: Record; spanParentAsLink?: boolean; oneTimeUseToken?: string; realtimeStreamsVersion?: "v1" | "v2"; triggerSource?: string; triggerAction?: string; }; type RunItemData = { id: string; isCached: boolean; idempotencyKey: string | undefined; taskIdentifier: string; }; /** * ### V3 * * BatchTrigger v3 doesn't have any changes from v2, other than a different system for tracking if the * batch is completed. * * v3 BatchTaskRun's now must be "sealed" before they could be considered completed. Being "sealed" means * that all the items in the batch have been processed and the batch is ready to be considered completed. * * We also now track the expected count of items in the batch, and then as each BatchTaskRunItem is set to COMPLETED, * we increment the BatchTaskRun's completed count. Once the completed count is equal to the expected count, and the * batch is sealed, we can consider the batch completed. * * So now when the v3 batch is considered completed, we will enqueue the ResumeBatchRunService to resume the dependent * task attempt if there is one. This is in contrast to v2 batches where every time a task was completed, we would schedule * the ResumeBatchRunService to check if the batch was completed and set it to completed if it was. * * We've also introduced a new column "resumedAt" that will be set when the batch is resumed. Previously in v2 batches, the status == "COMPLETED" was overloaded * to mean that the batch was completed and resumed. Now we have a separate column to track when the batch was resumed (and to make sure it's only resumed once). * * ### V2 * * Batch v2 added the ability to trigger more than 100 tasks in a single batch. This was done by offloading the payload to the object store and * then processing the batch in chunks of 50 tasks at a time in the background. * * The other main difference from v1 is that a single batch in v2 could trigger multiple different tasks, whereas in v1 a batch could only trigger a single task. */ export class BatchTriggerV3Service extends BaseService { private _batchProcessingStrategy: BatchProcessingStrategy; private _asyncBatchProcessSizeThreshold: number; constructor( batchProcessingStrategy?: BatchProcessingStrategy, asyncBatchProcessSizeThreshold: number = ASYNC_BATCH_PROCESS_SIZE_THRESHOLD, protected readonly _prisma: PrismaClientOrTransaction = prisma, protected readonly runStore: RunStore = defaultRunStore, // Injected so tests force the env-default branch deterministically; defaults // to the live per-env mint resolver. private readonly resolveMintKind: (environment: { organizationId: string; id: string; orgFeatureFlags?: unknown; }) => Promise = resolveRunIdMintKind ) { super(_prisma); this._batchProcessingStrategy = batchProcessingStrategy ?? "parallel"; this._asyncBatchProcessSizeThreshold = asyncBatchProcessSizeThreshold; } public async call( environment: AuthenticatedEnvironment, body: BatchTriggerTaskV2RequestBody, options: BatchTriggerTaskServiceOptions = {} ): Promise { try { return await this.traceWithEnv( "call()", environment, async (span) => { if (!body.items || body.items.length === 0) { throw new ServiceValidationError("A batch trigger must have at least one item"); } // BatchTaskRun.runtimeEnvironmentId no longer has an FK into RuntimeEnvironment; // validate env existence app-side before any create arm (passthrough when split is off). await controlPlaneResolver.assertEnvExists(environment.id); const existingBatch = options.idempotencyKey ? await this.runStore.findBatchTaskRunByIdempotencyKey( environment.id, 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.runStore.updateBatchTaskRun({ where: { id: existingBatch.id }, data: { idempotencyKey: null }, select: { id: true }, }); // Don't return, just continue with the batch trigger } else { span.setAttribute("batchId", existingBatch.friendlyId); return this.#respondWithExistingBatch(existingBatch, environment); } } const { id: batchInternalId, friendlyId: batchId } = await mintBatchFriendlyId({ environment: { organizationId: environment.organizationId, id: environment.id, orgFeatureFlags: environment.organization.featureFlags, }, parentRunFriendlyId: body.parentRunId, }); span.setAttribute("batchId", batchId); const dependentAttempt = body?.dependentAttempt ? await this._prisma.taskRunAttempt.findFirst({ // Scope to the caller's environment (see dependentAttemptWhere). where: dependentAttemptWhere(body.dependentAttempt, environment.id), 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 runs = await this.#prepareRunData(environment, body, batchId); const newRunCount = runs.filter((r) => !r.isCached).length; if (newRunCount === 0) { logger.debug("[BatchTriggerV2][call] All runs are cached", { batchId, }); await this.runStore.createBatchTaskRun({ id: batchInternalId, friendlyId: batchId, runtimeEnvironmentId: environment.id, idempotencyKey: options.idempotencyKey, idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, dependentTaskAttemptId: dependentAttempt?.id, runCount: body.items.length, runIds: runs.map((r) => r.id), status: "COMPLETED", batchVersion: "v3", oneTimeUseToken: options.oneTimeUseToken, }); return { id: batchId, isCached: false, idempotencyKey: options.idempotencyKey ?? undefined, runs, }; } 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}`, undefined, "warn" ); } // Upload to object store const payloadPacket = await this.#handlePayloadPacket( body.items, `batch/${batchId}`, environment ); const batch = await this.#createAndProcessBatchTaskRun( batchId, batchInternalId, runs, payloadPacket, newRunCount, environment, body, options, dependentAttempt ?? undefined ); if (!batch) { throw new Error("Failed to create batch"); } return { id: batch.friendlyId, isCached: false, idempotencyKey: batch.idempotencyKey ?? undefined, runs, }; } ); } catch (error) { // Detect a prisma transaction Unique constraint violation if (error instanceof Prisma.PrismaClientKnownRequestError) { logger.debug("BatchTriggerV2: Prisma transaction error", { code: error.code, message: error.message, meta: error.meta, }); if (error.code === "P2002") { const target = error.meta?.target; if ( Array.isArray(target) && target.length > 0 && typeof target[0] === "string" && target[0].includes("oneTimeUseToken") ) { throw new ServiceValidationError( "Cannot batch trigger with a one-time use token as it has already been used." ); } else { throw new ServiceValidationError( "Cannot batch trigger as it has already been triggered with the same idempotency key." ); } } } throw error; } } // Mint a child run's friendlyId so it lands in the SAME physical store as its // residency anchor. The caller passes the batch's friendlyId, so a run-ops id // (NEW) anchor yields a run-ops id (NEW) child and a cuid anchor yields a cuid // (LEGACY) child. With no anchor it falls back to the env's cutover setting. // Mirrors RunEngineTriggerTaskService.mintRunFriendlyId. private async mintChildFriendlyId( environment: AuthenticatedEnvironment, anchorFriendlyId?: string, region?: string ): Promise { const mintKind = anchorFriendlyId ? resolveInheritedMintKind(anchorFriendlyId) : await this.resolveMintKind({ organizationId: environment.organizationId, id: environment.id, orgFeatureFlags: environment.organization.featureFlags, }); return mintFriendlyIdForKind(mintKind, region); } async #prepareRunData( environment: AuthenticatedEnvironment, body: BatchTriggerTaskV2RequestBody, batchFriendlyId: string ): Promise> { // Anchor every child to the batch's residency: the batch friendlyId is // minted once, so deriving each child's id-kind from it — rather than re-resolving // the per-org flag, which can flip mid-batch — keeps batch + children co-resident. const childAnchor = batchFriendlyId; // batchTriggerAndWait cannot have cached runs because that does not work in run engine v1 and is not available in the client if (body?.dependentAttempt) { return Promise.all( body.items.map(async (item) => ({ id: await this.mintChildFriendlyId(environment, childAnchor, item.options?.region), isCached: false, idempotencyKey: undefined, taskIdentifier: item.task, })) ); } // Group items by taskIdentifier const itemsByTask = body.items.reduce( (acc, item) => { if (!item.options?.idempotencyKey) return acc; if (!acc[item.task]) { acc[item.task] = []; } acc[item.task].push(item); return acc; }, {} as Record ); logger.debug("[BatchTriggerV2][call] Grouped items by task identifier", { itemsByTask, }); // Fetch cached runs for each task identifier separately to make use of the index const cachedRuns = await Promise.all( Object.entries(itemsByTask).map(([taskIdentifier, items]) => this.runStore.findRuns( { where: { runtimeEnvironmentId: environment.id, taskIdentifier, idempotencyKey: { in: items.map((i) => i.options?.idempotencyKey).filter(Boolean), }, }, select: { friendlyId: true, idempotencyKey: true, idempotencyKeyExpiresAt: true, }, }, this._prisma ) ) ).then((results) => results.flat()); // Build the run IDs in order: reuse an unexpired cached id, else mint a new id (and record any // expired cached id so its idempotency key can be cleared below). const expiredRunIds = new Set(); const runs = await Promise.all( body.items.map(async (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 { id: await this.mintChildFriendlyId(environment, childAnchor, item.options?.region), isCached: false, idempotencyKey: item.options?.idempotencyKey ?? undefined, taskIdentifier: item.task, }; } return { id: cachedRun.friendlyId, isCached: true, idempotencyKey: item.options?.idempotencyKey ?? undefined, taskIdentifier: item.task, }; } return { id: await this.mintChildFriendlyId(environment, childAnchor, item.options?.region), isCached: false, idempotencyKey: item.options?.idempotencyKey ?? undefined, taskIdentifier: item.task, }; }) ); // Expire the cached runs that are no longer valid if (expiredRunIds.size) { await this.runStore.clearIdempotencyKey( { byFriendlyIds: Array.from(expiredRunIds) }, this._prisma ); } return runs; } async #createAndProcessBatchTaskRun( batchId: string, batchInternalId: string, runs: Array, payloadPacket: IOPacket, newRunCount: number, environment: AuthenticatedEnvironment, body: BatchTriggerTaskV2RequestBody, options: BatchTriggerTaskServiceOptions = {}, dependentAttempt?: TaskRunAttempt ) { if (runs.length <= this._asyncBatchProcessSizeThreshold) { const batch = await this.runStore.createBatchTaskRun({ id: batchInternalId, friendlyId: batchId, runtimeEnvironmentId: environment.id, idempotencyKey: options.idempotencyKey, idempotencyKeyExpiresAt: options.idempotencyKeyExpiresAt, dependentTaskAttemptId: dependentAttempt?.id, runCount: runs.length, runIds: runs.map((r) => r.id), payload: payloadPacket.data, payloadType: payloadPacket.dataType, options, batchVersion: "v3", oneTimeUseToken: options.oneTimeUseToken, }); const result = await this.#processBatchTaskRunItems( batch, environment, 0, PROCESSING_BATCH_SIZE, body.items, options ); if (result.error) { logger.error("[BatchTriggerV2][call] Batch inline processing error", { batchId: batch.friendlyId, currentIndex: result.workingIndex, error: result.error, }); await this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { status: "ABORTED", completedAt: new Date(), }, select: { id: true }, }); throw result.error; } await this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { sealed: true, sealedAt: new Date() }, select: { id: true }, }); return batch; } else { const batch = await this.runStore.createBatchTaskRun({ id: batchInternalId, 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, batchVersion: "v3", oneTimeUseToken: options.oneTimeUseToken, }); switch (this._batchProcessingStrategy) { case "sequential": { await this.#enqueueBatchTaskRun({ batchId: batch.id, processingId: batchId, range: { start: 0, count: PROCESSING_BATCH_SIZE }, attemptCount: 0, strategy: this._batchProcessingStrategy, }); 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 this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { processingJobsExpectedCount: ranges.length, }, select: { id: true }, }); await Promise.all( ranges.map((range, index) => this.#enqueueBatchTaskRun({ batchId: batch.id, processingId: `${index}`, range, attemptCount: 0, strategy: this._batchProcessingStrategy, }) ) ); break; } } return batch; } } 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(options: BatchProcessingOptions) { logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch", { options, }); const $attemptCount = options.attemptCount + 1; if ($attemptCount > MAX_ATTEMPTS) { logger.error("[BatchTriggerV2][processBatchTaskRun] Max attempts reached", { options, attemptCount: $attemptCount, }); return; } const batch = await this.runStore.findBatchTaskRunById(options.batchId); if (!batch) { return; } // BatchTaskRun -> RuntimeEnvironment FK is dropped; resolve the env from the scalar id. const environment = await findEnvironmentById(batch.runtimeEnvironmentId); if (!environment) { logger.error("[BatchTriggerV2][processBatchTaskRun] Environment not found", { batchId: batch.id, runtimeEnvironmentId: batch.runtimeEnvironmentId, }); return; } if (options.range.start >= batch.runCount) { logger.debug("[BatchTriggerV2][processBatchTaskRun] currentIndex is greater than runCount", { options, batchId: batch.friendlyId, runCount: batch.runCount, attemptCount: $attemptCount, }); return; } // Resolve the payload const payloadPacket = await downloadPacketFromObjectStore( { data: batch.payload ?? undefined, dataType: batch.payloadType, }, environment ); const payload = await parsePacket(payloadPacket); if (!payload) { logger.debug("[BatchTriggerV2][processBatchTaskRun] Failed to parse payload", { options, batchId: batch.friendlyId, attemptCount: $attemptCount, }); throw new Error("Failed to parse payload"); } // Skip zod parsing const $payload = payload as BatchTriggerTaskV2RequestBody["items"]; const $options = batch.options as BatchTriggerTaskServiceOptions; const result = await this.#processBatchTaskRunItems( batch, environment, options.range.start, options.range.count, $payload, $options ); if (result.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 await this.#enqueueBatchTaskRun({ batchId: batch.id, processingId: options.processingId, range: { start: result.workingIndex, count: options.strategy === "sequential" ? options.range.count : options.range.count - result.workingIndex - options.range.start, }, attemptCount: $attemptCount, strategy: options.strategy, }); return; } switch (options.strategy) { case "sequential": { // Done once we've walked past the last item in the batch if (result.workingIndex >= batch.runCount) { await this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { sealed: true, sealedAt: new Date() }, select: { id: true }, }); logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing complete", { batchId: batch.friendlyId, runCount: batch.runCount, currentIndex: result.workingIndex, attemptCount: $attemptCount, }); } else { await this.#enqueueBatchTaskRun({ batchId: batch.id, processingId: options.processingId, range: { start: result.workingIndex, count: options.range.count, }, attemptCount: 0, strategy: options.strategy, }); } break; } case "parallel": { // Each processing job increments the count; the last one to arrive seals the batch const { processingJobsCount, processingJobsExpectedCount } = await this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { processingJobsCount: { increment: 1, }, }, select: { processingJobsExpectedCount: true, processingJobsCount: true, }, }); if (processingJobsCount >= processingJobsExpectedCount) { await this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { sealed: true, sealedAt: new Date() }, select: { id: true }, }); logger.debug("[BatchTriggerV2][processBatchTaskRun] Batch processing complete", { batchId: batch.friendlyId, currentIndex: result.workingIndex, attemptCount: $attemptCount, }); } } } } async #processBatchTaskRunItems( batch: BatchTaskRun, environment: AuthenticatedEnvironment, currentIndex: number, batchSize: number, items: BatchTriggerTaskV2RequestBody["items"], options?: BatchTriggerTaskServiceOptions ): Promise<{ workingIndex: number; error?: Error }> { const runIds = batch.runIds.slice(currentIndex, currentIndex + batchSize); logger.debug("[BatchTriggerV2][processBatchTaskRun] Processing batch items", { batchId: batch.friendlyId, currentIndex, runIds, runCount: batch.runCount, }); // Pair each runId in this window with its item from the payload array const itemsToProcess = runIds.map((runId, index) => ({ runId, item: items[index + currentIndex], })); let workingIndex = currentIndex; let expectedCount = 0; for (const item of itemsToProcess) { try { const created = await this.#processBatchTaskRunItem( batch, environment, item, workingIndex, options ); if (created) { expectedCount++; } workingIndex++; } catch (error) { logger.error("[BatchTriggerV2][processBatchTaskRun] Failed to process item", { batchId: batch.friendlyId, currentIndex: workingIndex, error, }); return { error: error instanceof Error ? error : new Error(String(error)), workingIndex, }; } } if (expectedCount > 0) { await this.runStore.updateBatchTaskRun({ where: { id: batch.id }, data: { expectedCount: { increment: expectedCount, }, }, select: { id: true }, }); } return { workingIndex }; } 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, }); const triggerTaskService = new TriggerTaskService(); const result = 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.id, skipChecks: true, runFriendlyId: task.runId, realtimeStreamsVersion: options?.realtimeStreamsVersion, triggerSource: options?.triggerSource ?? "api", triggerAction: options?.triggerAction ?? "trigger", } ); if (!result) { throw new Error(`Failed to trigger run ${task.runId} for batch ${batch.friendlyId}`); } if (!result.isCached) { try { await this.runStore.createBatchTaskRunItem({ batchTaskRunId: batch.id, taskRunId: result.run.id, status: batchTaskRunItemStatusForRunStatus(result.run.status), }); return true; } catch (error) { if (isUniqueConstraintError(error, ["batchTaskRunId", "taskRunId"])) { // This means there is already a batchTaskRunItem for this batch and taskRun logger.debug( "[BatchTriggerV2][processBatchTaskRunItem] BatchTaskRunItem already exists", { batchId: batch.friendlyId, runId: task.runId, currentIndex, } ); return false; } throw error; } } return false; } async #enqueueBatchTaskRun(options: BatchProcessingOptions) { await batchTriggerWorker.enqueue({ id: `BatchTriggerV2Service.process:${options.batchId}:${options.processingId}`, job: "v3.processBatchTaskRun", payload: options, }); } 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`; const uploadedFilename = await uploadPacketToObjectStore( filename, packet.data, packet.dataType, environment ); return { data: uploadedFilename, dataType: "application/store", }; }); } } export async function tryCompleteBatchV3( batchId: string, tx: PrismaClientOrTransaction, scheduleResumeOnComplete: boolean, // Threaded in so a run-ops id (NEW-resident) batch + its items are read/written on the owning // store, not the control-plane `tx`. Defaults to the singleton (single-DB = passthrough). runStore: RunStore = defaultRunStore ) { const batch = await runStore.findBatchTaskRunById(batchId); if (!batch) { logger.debug("tryCompleteBatchV3: Batch not found", { batchId }); return; } if (batch.status === "COMPLETED") { logger.debug("tryCompleteBatchV3: Already completed", { batchId }); return; } if (!batch.sealed) { logger.debug("tryCompleteBatchV3: Not sealed yet", { batchId }); return; } const completedCount = await runStore.countBatchTaskRunItems({ batchTaskRunId: batchId, status: "COMPLETED", }); if (completedCount < batch.expectedCount) { logger.debug("tryCompleteBatchV3: Not all items completed", { batchId, completedCount, expectedCount: batch.expectedCount, }); return; } // Mark batch COMPLETED (idempotent via status check) const updated = await runStore.updateManyBatchTaskRun({ where: { id: batchId, status: "PENDING" }, data: { status: "COMPLETED", completedAt: new Date(), completedCount }, }); if (updated.count === 0) { logger.debug("tryCompleteBatchV3: Already transitioned", { batchId }); return; } logger.debug("tryCompleteBatchV3: Batch completed", { batchId, completedCount }); if (scheduleResumeOnComplete && batch.dependentTaskAttemptId) { await ResumeBatchRunService.enqueue(batchId, true, tx); } } export async function completeBatchTaskRunItemV3( itemId: string, batchTaskRunId: string, tx: PrismaClientOrTransaction, scheduleResumeOnComplete = false, taskRunAttemptId?: string, retryAttempt?: number, // Threaded in so a run-ops id (NEW-resident) batch's item lands on the owning store; route by // batchTaskRunId (items co-reside with their batch). Defaults to the singleton. runStore: RunStore = defaultRunStore ) { const isRetry = retryAttempt !== undefined; logger.debug("completeBatchTaskRunItemV3", { itemId, batchTaskRunId, scheduleResumeOnComplete, taskRunAttemptId, retryAttempt, isRetry, }); try { // Update item to COMPLETED (no transaction needed, no contention). Routed by // batchTaskRunId so the item write lands on the batch's owning DB. const updated = await runStore.updateManyBatchTaskRunItems({ where: { id: itemId, batchTaskRunId, status: "PENDING" }, data: { status: "COMPLETED", taskRunAttemptId }, }); if (updated.count === 0) { logger.debug("completeBatchTaskRunItemV3: Item already completed", { itemId, batchTaskRunId, }); return; } // Schedule debounced completion check // enqueue with same ID overwrites, resetting the 200ms timer (debounce behavior) await legacyRunEngineWorker.enqueue({ id: `tryCompleteBatchV3:${batchTaskRunId}`, job: "tryCompleteBatchV3", payload: { batchId: batchTaskRunId, scheduleResumeOnComplete }, availableAt: new Date(Date.now() + 200), }); } catch (error) { if (isPrismaRetriableError(error) || isPrismaRaceConditionError(error)) { logger.error("completeBatchTaskRunItemV3 failed, scheduling retry", { itemId, batchTaskRunId, error, retryAttempt, isRetry, }); if (isRetry) { throw error; } else { await legacyRunEngineWorker.enqueue({ id: `completeBatchTaskRunItem:${itemId}`, job: "completeBatchTaskRunItem", payload: { itemId, batchTaskRunId, scheduleResumeOnComplete, taskRunAttemptId }, availableAt: new Date(Date.now() + 2_000), }); } } else { logger.error("completeBatchTaskRunItemV3 failed with non-retriable error", { itemId, batchTaskRunId, error, retryAttempt, isRetry, }); } } }