/** * Pure, store-routing helpers extracted from runEngineHandlers.server.ts so they * are testable without constructing the engine (importing that module pulls in the * whole webapp service graph). The handlers wire the production defaults; tests * inject per-container stores/replicas, so these helpers never import db.server. */ import type { CompleteBatchResult } from "@internal/run-engine"; import type { RunStore } from "@internal/run-store"; import type { BatchTaskRunStatus, Prisma } from "@trigger.dev/database"; import type { PrismaClient, PrismaReplicaClient } from "~/db.server"; import { logger } from "~/services/logger.server"; import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server"; export type EventReadDeps = { store: RunStore; newReplica: PrismaReplicaClient; legacyReplica: PrismaReplicaClient; splitEnabled: boolean; // Pure boundary forwarded to read-through; production leaves it undefined // so the read-through layer uses its own wired default. Tests inject a fake. isPastRetention?: (runId: string) => boolean; }; /** * Resolve a TaskRun for an event-bus enrichment read through the run-ops * read-through layer. The store stays the read mechanism (the * closures call `store.findRun(...)`); read-through only chooses which replica. * Returns null when not-found / past-retention. Passthrough in single-DB. */ export async function readRunForEvent( runId: string, environmentId: string, select: S, deps: EventReadDeps ): Promise | null> { const result = await readThroughRun>({ runId, environmentId, readNew: (client) => deps.store.findRun({ id: runId }, { select }, client), readLegacy: (replica) => deps.store.findRun({ id: runId }, { select }, replica), deps: { newClient: deps.newReplica, legacyReplica: deps.legacyReplica, splitEnabled: deps.splitEnabled, isPastRetention: deps.isPastRetention, }, }); return result.source === "not-found" || result.source === "past-retention" ? null : result.value; } /** * Reproduces the `findRunOrThrow` not-found-as-error semantics the 6 throwing * read sites rely on (a missing run throws, which their `tryCatch` turns into * the existing error-log + early-return — never a silent no-op). */ export async function readRunForEventOrThrow( runId: string, environmentId: string, select: S, deps: EventReadDeps ): Promise> { const run = await readRunForEvent(runId, environmentId, select, deps); if (!run) { throw new Error("Task run not found"); } return run; } /** * Resolve which run-ops writer physically owns the `BatchTaskRun` row for * `batchId` by probing where the row lives, so the batch-completion txn commits * on a single run-ops DB. Length classification is INVALID here: a batch id may * be a run-ops id (cut-over orgs) or a cuid (and cuid-shaped ids can be backfilled * onto NEW), so id-shape does not reliably indicate the row's actual residency. * The existence probe is the correct signal. */ export async function resolveBatchRunOpsWriter( batchId: string, deps: { newReplica: PrismaReplicaClient; newWriter: PrismaClient; legacyWriter: PrismaClient; } ): Promise { const onNew = await deps.newReplica.batchTaskRun.findFirst({ where: { id: batchId }, select: { id: true }, }); return onNew ? deps.newWriter : deps.legacyWriter; } /** * errorCode returned by the batch process-item callback when the trigger was * rejected because the environment's queue is at its maximum size. The * BatchQueue (via `skipRetries`) short-circuits retries for this code, and the * batch completion callback collapses per-item errors into a single aggregate * `BatchTaskRunError` row instead of writing one per item. */ export const QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE = "QUEUE_SIZE_LIMIT_EXCEEDED"; export type BatchCompletionDeps = { splitEnabled: boolean; newReplica: PrismaReplicaClient; newWriter: PrismaClient; legacyWriter: PrismaClient; tryCompleteBatch: (batchId: string) => Promise; }; /** * Routes the batch-completion transaction (BatchTaskRun update + BatchTaskRunError * createMany — both run-ops tables) onto the run-ops writer that physically owns * the BatchTaskRun row for `batchId`, so the whole txn commits on a single DB. The * transaction body is unchanged from before the split; only the client changes. */ export async function handleBatchCompletion( result: CompleteBatchResult, deps: BatchCompletionDeps ) { const { batchId, runIds, successfulRunCount, failedRunCount, failures } = result; // Determine final status let status: BatchTaskRunStatus; if (failedRunCount > 0 && successfulRunCount === 0) { status = "ABORTED"; } else if (failedRunCount > 0) { status = "PARTIAL_FAILED"; } else { status = "PENDING"; // All runs created, waiting for completion } // Always probe residency — never special-case on splitEnabled (see commit msg). const runOpsWriter = await resolveBatchRunOpsWriter(batchId, { newReplica: deps.newReplica, newWriter: deps.newWriter, legacyWriter: deps.legacyWriter, }); try { // Use a transaction to ensure atomicity of batch update and error record creation // skipDuplicates handles idempotency when callback is retried (relies on unique constraint) await runOpsWriter.$transaction(async (tx) => { // Update BatchTaskRun await tx.batchTaskRun.update({ where: { id: batchId }, data: { status, runIds, successfulRunCount, failedRunCount, completedAt: status === "ABORTED" ? new Date() : undefined, processingCompletedAt: new Date(), }, }); // Create error records if there were failures. // // Fast-path for queue-size-limit overload: when every failure is the // same QUEUE_SIZE_LIMIT_EXCEEDED error, collapse them into a single // aggregate row instead of writing one per item. This keeps the DB // write volume bounded to O(batches) instead of O(items) when a noisy // tenant fills their queue and all of their batches start bouncing. if (failures.length > 0) { const allQueueSizeLimit = failures.every( (f) => f.errorCode === QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE ); if (allQueueSizeLimit) { const sample = failures[0]!; await tx.batchTaskRunError.createMany({ data: [ { batchTaskRunId: batchId, // Use the first item's index as a stable anchor for the // (batchTaskRunId, index) unique constraint so callback // retries remain idempotent. index: sample.index, taskIdentifier: sample.taskIdentifier, payload: sample.payload, options: sample.options as Prisma.InputJsonValue | undefined, error: `${sample.error} (${failures.length} items in this batch failed with the same error)`, errorCode: sample.errorCode, }, ], skipDuplicates: true, }); } else { await tx.batchTaskRunError.createMany({ data: failures.map((failure) => ({ batchTaskRunId: batchId, index: failure.index, taskIdentifier: failure.taskIdentifier, payload: failure.payload, options: failure.options as Prisma.InputJsonValue | undefined, error: failure.error, errorCode: failure.errorCode, })), skipDuplicates: true, }); } } }); // Try to complete the batch (handles waitpoint completion if all runs are done) if (status !== "ABORTED") { await deps.tryCompleteBatch(batchId); } logger.info("Batch completion handled", { batchId, status, successfulRunCount, failedRunCount, }); } catch (error) { logger.error("Failed to handle batch completion", { batchId, error: error instanceof Error ? error.message : String(error), }); // Re-throw to preserve Redis data for retry (BatchQueue expects errors to propagate) throw error; } }