Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/runEngineHandlers.server.ts
T
2026-07-13 13:32:57 +08:00

1100 lines
35 KiB
TypeScript

import type { CompleteBatchResult } from "@internal/run-engine";
import { SpanKind } from "@internal/tracing";
import { tryCatch } from "@trigger.dev/core/utils";
import { createJsonErrorObject, sanitizeError, TaskRunErrorCodes } from "@trigger.dev/core/v3";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import {
$replica,
prisma,
runOpsLegacyPrisma,
runOpsNewPrisma,
runOpsNewReplica,
runOpsLegacyReplica,
} from "~/db.server";
import { env } from "~/env.server";
import { findEnvironmentById, findEnvironmentFromRun } from "~/models/runtimeEnvironment.server";
import { TriggerFailedTaskService } from "~/runEngine/services/triggerFailedTask.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { updateMetadataService } from "~/services/metadata/updateMetadataInstance.server";
import { reportInvocationUsage } from "~/services/platform.v3.server";
import { publishChangeRecord } from "~/services/realtime/runChangeNotifierInstance.server";
import { MetadataTooLargeError } from "~/utils/packets";
import { QueueSizeLimitExceededError } from "~/v3/services/common.server";
import { TriggerTaskService } from "~/v3/services/triggerTask.server";
import { tracer } from "~/v3/tracer.server";
import { createExceptionPropertiesFromError } from "./eventRepository/common.server";
import { getEventRepositoryForStore, recordRunDebugLog } from "./eventRepository/index.server";
import { roomFromFriendlyRunId, socketIo } from "./handleSocketIo.server";
import { engine } from "./runEngine.server";
import { runStore } from "./runStore.server";
import { mintAnchoredRunFriendlyId } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server";
import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
import { PerformTaskRunAlertsService } from "./services/alerts/performTaskRunAlerts.server";
import {
handleBatchCompletion,
QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE,
readRunForEvent,
readRunForEventOrThrow,
type EventReadDeps,
} from "./runEngineHandlersShared.server";
export function registerRunEngineEventBusHandlers() {
// Resolve the split-mode gate ONCE at registration scope (never per-event).
const splitEnabledPromise = isSplitEnabled();
const eventReadDeps = async (): Promise<EventReadDeps> => ({
store: runStore,
newReplica: runOpsNewReplica,
legacyReplica: runOpsLegacyReplica,
splitEnabled: await splitEnabledPromise,
});
engine.eventBus.on("runSucceeded", async ({ time, run, organization, environment }) => {
const [taskRunError, taskRun] = await tryCatch(
readRunForEventOrThrow(
run.id,
environment.id,
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
taskEventStore: true,
// Piggyback the realtime run-changed publish on this existing read so the
// per-env channel carries the membership keys (no separate query). No-op when
// the native backend is disabled.
runTags: true,
batchId: true,
},
await eventReadDeps()
)
);
if (taskRunError) {
logger.error("[runSucceeded] Failed to find task run", {
error: taskRunError,
runId: run.id,
});
return;
}
publishChangeRecord({
runId: taskRun.id,
envId: environment.id,
tags: taskRun.runTags,
batchId: taskRun.batchId,
updatedAtMs: run.updatedAt.getTime(),
});
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
taskRun.organizationId ?? organization.id
);
const [completeSuccessfulRunEventError] = await tryCatch(
eventRepository.completeSuccessfulRunEvent({
run: taskRun,
endTime: time,
})
);
if (completeSuccessfulRunEventError) {
logger.error("[runSucceeded] Failed to complete successful run event", {
error: completeSuccessfulRunEventError,
runId: run.id,
});
}
});
// Handle alerts
engine.eventBus.on("runFailed", async ({ time, run }) => {
try {
await PerformTaskRunAlertsService.enqueue(run.id);
} catch (error) {
logger.error("[runFailed] Failed to enqueue alerts", {
error: error instanceof Error ? error.message : error,
runId: run.id,
spanId: run.spanId,
});
}
});
// Handle events
engine.eventBus.on("runFailed", async ({ time, run, organization, environment }) => {
const sanitizedError = sanitizeError(run.error);
const exception = createExceptionPropertiesFromError(sanitizedError);
const [taskRunError, taskRun] = await tryCatch(
readRunForEventOrThrow(
run.id,
environment.id,
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
taskEventStore: true,
// Piggyback the realtime run-changed publish on this existing read (no-op when
// the native backend is disabled).
runTags: true,
batchId: true,
},
await eventReadDeps()
)
);
if (taskRunError) {
logger.error("[runFailed] Failed to find task run", {
error: taskRunError,
runId: run.id,
});
return;
}
publishChangeRecord({
runId: taskRun.id,
envId: environment.id,
tags: taskRun.runTags,
batchId: taskRun.batchId,
updatedAtMs: run.updatedAt.getTime(),
});
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
taskRun.organizationId ?? organization.id
);
const [completeFailedRunEventError] = await tryCatch(
eventRepository.completeFailedRunEvent({
run: taskRun,
endTime: time,
exception,
})
);
if (completeFailedRunEventError) {
logger.error("[runFailed] Failed to complete failed run event", {
error: completeFailedRunEventError,
runId: run.id,
});
}
});
engine.eventBus.on("runAttemptFailed", async ({ time, run }) => {
const sanitizedError = sanitizeError(run.error);
const exception = createExceptionPropertiesFromError(sanitizedError);
const [taskRunError, taskRun] = await tryCatch(
readRunForEventOrThrow(
run.id,
// runAttemptFailed carries no environment param; the env is derived from
// the read row afterwards. environmentId is informational for read-through
// (residency is keyed on runId), so an empty value is safe here.
"",
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
taskEventStore: true,
// Piggyback the realtime run-changed publish on this existing read (no-op when
// the native backend is disabled).
runTags: true,
batchId: true,
},
await eventReadDeps()
)
);
if (taskRunError) {
logger.error("[runAttemptFailed] Failed to find task run", {
error: taskRunError,
runId: run.id,
});
return;
}
publishChangeRecord({
runId: taskRun.id,
envId: taskRun.runtimeEnvironmentId,
tags: taskRun.runTags,
batchId: taskRun.batchId,
updatedAtMs: run.updatedAt.getTime(),
});
if (!taskRun.organizationId) {
logger.error("[runAttemptFailed] Task run has no organization id", {
runId: run.id,
});
return;
}
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore,
taskRun.organizationId
);
const [createAttemptFailedRunEventError] = await tryCatch(
eventRepository.createAttemptFailedRunEvent({
run: taskRun,
endTime: time,
attemptNumber: run.attemptNumber,
exception,
})
);
if (createAttemptFailedRunEventError) {
logger.error("[runAttemptFailed] Failed to create attempt failed run event", {
error: createAttemptFailedRunEventError,
runId: run.id,
});
}
});
engine.eventBus.on(
"cachedRunCompleted",
async ({ time, span, blockedRunId, hasError, cachedRunId }) => {
const [parentSpanId, spanId] = span.id.split(":");
if (!spanId || !parentSpanId) {
logger.debug("[cachedRunCompleted] Invalid span id", {
spanId,
parentSpanId,
});
return;
}
const deps = await eventReadDeps();
const [cachedRunError, cachedRun] = await tryCatch(
readRunForEventOrThrow(
cachedRunId ?? "",
"",
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
},
deps
)
);
if (cachedRunError) {
logger.error("[cachedRunCompleted] Failed to find cached run", {
error: cachedRunError,
cachedRunId,
});
return;
}
const [blockedRunError, blockedRun] = await tryCatch(
readRunForEvent(
blockedRunId,
"",
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
taskEventStore: true,
},
deps
)
);
if (blockedRunError) {
logger.error("[cachedRunCompleted] Failed to find blocked run", {
error: blockedRunError,
blockedRunId,
});
}
if (!blockedRun) {
logger.error("[cachedRunCompleted] Blocked run not found", {
blockedRunId,
});
return;
}
if (!blockedRun.organizationId) {
logger.error("[cachedRunCompleted] Blocked run has no organization id", {
blockedRunId,
});
return;
}
const eventRepository = await getEventRepositoryForStore(
blockedRun.taskEventStore,
blockedRun.organizationId
);
const [completeCachedRunEventError] = await tryCatch(
eventRepository.completeCachedRunEvent({
run: cachedRun,
blockedRun,
spanId,
parentSpanId,
spanCreatedAt: span.createdAt,
isError: hasError,
endTime: time,
})
);
if (completeCachedRunEventError) {
logger.error("[cachedRunCompleted] Failed to complete cached run event", {
error: completeCachedRunEventError,
cachedRunId,
});
}
}
);
engine.eventBus.on("runExpired", async ({ time, run, organization, environment }) => {
if (!run.ttl) {
return;
}
const [taskRunError, taskRun] = await tryCatch(
readRunForEventOrThrow(
run.id,
environment.id,
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
taskEventStore: true,
// Piggyback the realtime run-changed publish on this existing read (no-op when
// the native backend is disabled).
runTags: true,
batchId: true,
},
await eventReadDeps()
)
);
if (taskRunError) {
logger.error("[runExpired] Failed to find task run", {
error: taskRunError,
runId: run.id,
});
return;
}
publishChangeRecord({
runId: taskRun.id,
envId: environment.id,
tags: taskRun.runTags,
batchId: taskRun.batchId,
updatedAtMs: run.updatedAt.getTime(),
});
const eventRepository = await getEventRepositoryForStore(
taskRun.taskEventStore,
taskRun.organizationId ?? organization.id
);
const [completeExpiredRunEventError] = await tryCatch(
eventRepository.completeExpiredRunEvent({
run: taskRun,
endTime: time,
ttl: run.ttl,
})
);
if (completeExpiredRunEventError) {
logger.error("[runExpired] Failed to complete expired run event", {
error: completeExpiredRunEventError,
runId: run.id,
});
}
});
engine.eventBus.on("runCancelled", async ({ time, run, organization, environment }) => {
const [taskRunError, taskRun] = await tryCatch(
readRunForEventOrThrow(
run.id,
environment.id,
{
id: true,
friendlyId: true,
traceId: true,
spanId: true,
parentSpanId: true,
createdAt: true,
completedAt: true,
taskIdentifier: true,
projectId: true,
runtimeEnvironmentId: true,
environmentType: true,
isTest: true,
organizationId: true,
taskEventStore: true,
// Piggyback the realtime run-changed publish on this existing read (no-op when
// the native backend is disabled).
runTags: true,
batchId: true,
},
await eventReadDeps()
)
);
if (taskRunError) {
logger.error("[runCancelled] Task run not found", {
error: taskRunError,
runId: run.id,
});
return;
}
publishChangeRecord({
runId: taskRun.id,
envId: environment.id,
tags: taskRun.runTags,
batchId: taskRun.batchId,
updatedAtMs: run.updatedAt.getTime(),
});
const eventRepository = await getEventRepositoryForStore(
taskRun.taskEventStore,
taskRun.organizationId ?? organization.id
);
const error = createJsonErrorObject(run.error);
const [cancelRunEventError] = await tryCatch(
eventRepository.cancelRunEvent({
reason: error.message,
run: taskRun,
cancelledAt: time,
})
);
if (cancelRunEventError) {
logger.error("[runCancelled] Failed to cancel run event", {
error: cancelRunEventError,
runId: run.id,
});
}
});
engine.eventBus.on(
"runRetryScheduled",
async ({ time, run, environment, retryAt, organization }) => {
try {
if (retryAt && time && time >= retryAt) {
return;
}
let retryMessage = `Retry ${
typeof run.attemptNumber === "number" ? `#${run.attemptNumber - 1}` : ""
} delay`;
if (run.nextMachineAfterOOM) {
retryMessage += ` after OOM`;
}
const eventRepository = await getEventRepositoryForStore(
run.taskEventStore ?? "taskEvent",
organization.id
);
await eventRepository.recordEvent(retryMessage, {
startTime: BigInt(time.getTime() * 1000000),
taskSlug: run.taskIdentifier,
environment,
attributes: {
properties: {
retryAt: retryAt.toISOString(),
nextMachine: run.nextMachineAfterOOM,
},
runId: run.friendlyId,
style: {
icon: "schedule-attempt",
},
},
context: run.traceContext as Record<string, string | undefined>,
endTime: retryAt,
});
} catch (error) {
logger.error("[runRetryScheduled] Failed to record retry event", {
error: error instanceof Error ? error.message : error,
runId: run.id,
spanId: run.spanId,
});
}
}
);
engine.eventBus.on("runAttemptStarted", async ({ time, run, organization }) => {
try {
if (run.attemptNumber === 1 && run.baseCostInCents > 0) {
await reportInvocationUsage(organization.id, run.baseCostInCents, { runId: run.id });
}
} catch (error) {
logger.error("[runAttemptStarted] Failed to report invocation usage", {
error: error instanceof Error ? error.message : error,
runId: run.id,
orgId: organization.id,
});
}
});
engine.eventBus.on("runMetadataUpdated", async ({ time, run }) => {
const result = await findEnvironmentFromRun(run.id);
if (!result) {
logger.error("[runMetadataUpdated] Failed to find environment", { runId: run.id });
return;
}
const { environment, runTags, batchId } = result;
try {
const updateResult = await updateMetadataService.call(run.id, run.metadata, environment);
// Realtime run-changed publish, after the write so the router's hydrate sees the new
// row. A full record (env + tags + batchId + the committed updatedAt watermark), so
// feeds route by index. Nothing written here (no-op or buffered) = nothing to announce.
if (updateResult?.updatedAtMs !== undefined) {
publishChangeRecord({
runId: run.id,
envId: environment.id,
tags: runTags,
batchId,
updatedAtMs: updateResult.updatedAtMs,
});
}
} catch (e) {
if (e instanceof MetadataTooLargeError) {
logger.warn("[runMetadataUpdated] Failed to update metadata, too large", {
taskRun: run.id,
error:
e instanceof Error
? {
name: e.name,
message: e.message,
stack: e.stack,
}
: e,
});
} else {
logger.error("[runMetadataUpdated] Failed to update metadata", {
taskRun: run.id,
error:
e instanceof Error
? {
name: e.name,
message: e.message,
stack: e.stack,
}
: e,
});
}
}
});
engine.eventBus.on("executionSnapshotCreated", async ({ time, run, snapshot }) => {
const eventResult = await recordRunDebugLog(
run.id,
`[engine] ${snapshot.executionStatus} - ${snapshot.description}`,
{
attributes: {
properties: {
snapshotId: snapshot.id,
snapshotDescription: snapshot.description,
snapshotStatus: snapshot.executionStatus,
workerId: snapshot.workerId ?? undefined,
runnerId: snapshot.runnerId ?? undefined,
},
},
startTime: time,
}
);
if (!eventResult.success) {
logger.error("[executionSnapshotCreated] Failed to record event", {
runId: run.id,
snapshot,
error: eventResult.error,
});
}
});
engine.eventBus.on("workerNotification", async ({ time, run, snapshot }) => {
logger.debug("[workerNotification] Notifying worker", { time, runId: run.id, snapshot });
// Notify the worker
try {
const runFriendlyId = RunId.toFriendlyId(run.id);
const room = roomFromFriendlyRunId(runFriendlyId);
//send the notification to connected workers
socketIo.workerNamespace
.to(room)
.emit("run:notify", { version: "1", run: { friendlyId: runFriendlyId } });
//send the notification to connected dev workers
socketIo.devWorkerNamespace
.to(room)
.emit("run:notify", { version: "1", run: { friendlyId: runFriendlyId } });
if (!env.RUN_ENGINE_DEBUG_WORKER_NOTIFICATIONS) {
return;
}
// Record notification event
const eventResult = await recordRunDebugLog(
run.id,
// don't prefix this with [engine] - "run:notify" is the correct prefix
`run:notify platform -> supervisor: ${snapshot.executionStatus}`,
{
attributes: {
properties: {
snapshotId: snapshot.id,
snapshotStatus: snapshot.executionStatus,
},
},
startTime: time,
}
);
if (!eventResult.success) {
logger.error("[workerNotification] Failed to record event", {
runId: run.id,
snapshot,
error: eventResult.error,
});
}
} catch (error) {
logger.error("[workerNotification] Failed to notify worker", {
error: error instanceof Error ? error.message : error,
runId: run.id,
snapshot,
});
// Record notification event
const eventResult = await recordRunDebugLog(
run.id,
// don't prefix this with [engine] - "run:notify" is the correct prefix
`run:notify ERROR platform -> supervisor: ${snapshot.executionStatus}`,
{
attributes: {
properties: {
snapshotId: snapshot.id,
snapshotStatus: snapshot.executionStatus,
error: error instanceof Error ? error.message : String(error),
},
},
startTime: time,
}
);
if (!eventResult.success) {
logger.error("[workerNotification] Failed to record event", {
runId: run.id,
snapshot,
error: eventResult.error,
});
}
}
});
engine.eventBus.on("incomingCheckpointDiscarded", async ({ time, run, snapshot, checkpoint }) => {
const eventResult = await recordRunDebugLog(
run.id,
`[engine] Checkpoint discarded: ${checkpoint.discardReason}`,
{
attributes: {
properties: {
snapshotId: snapshot.id,
...checkpoint.metadata,
},
},
startTime: time,
}
);
if (!eventResult.success) {
logger.error("[incomingCheckpointDiscarded] Failed to record event", {
runId: run.id,
snapshot,
error: eventResult.error,
});
}
});
}
/**
* Set up the BatchQueue processing callbacks.
* These handle creating runs from batch items and completing batches.
*
* Payload handling:
* - If payloadType is "application/store", the payload is an R2 path (already offloaded)
* - DefaultPayloadProcessor in TriggerTaskService will pass it through without re-offloading
* - The run engine will download from R2 when the task executes
*/
export function setupBatchQueueCallbacks() {
// Resolve the split-mode gate ONCE at registration scope (never per-callback).
const splitEnabledPromise = isSplitEnabled();
// Item processing callback - creates a run for each batch item
engine.setBatchProcessItemCallback(
async ({ batchId, friendlyId, itemIndex, item, meta, attempt, isFinalAttempt }) => {
return tracer.startActiveSpan(
"batch.processItem",
{
kind: SpanKind.INTERNAL,
attributes: {
"batch.id": friendlyId,
"batch.item_index": itemIndex,
"batch.task": item.task,
"batch.environment_id": meta.environmentId,
"batch.parent_run_id": meta.parentRunId ?? "",
"batch.attempt": attempt,
"batch.is_final_attempt": isFinalAttempt,
},
},
async (span) => {
// Anchor every item mint on the BATCH's friendlyId so a mid-batch mint-flag flip
// can't split an item (or pre-failed item) from its BatchTaskRun row.
const mintItemRunFriendlyId = () =>
mintAnchoredRunFriendlyId(
friendlyId,
(item.options as { region?: string } | undefined)?.region
);
const triggerFailedTaskService = new TriggerFailedTaskService({
prisma,
engine,
replicaPrisma: $replica,
});
// Check for pre-marked error items (e.g. oversized payloads)
const itemError = item.options?.__error as string | undefined;
if (itemError) {
const errorCode = (item.options?.__errorCode as string) ?? "ITEM_ERROR";
let environment: AuthenticatedEnvironment | undefined;
try {
environment = (await findEnvironmentById(meta.environmentId)) ?? undefined;
} catch {
// Best-effort environment lookup
}
if (environment) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload ?? "{}",
payloadType: item.payloadType as string,
errorMessage: itemError,
errorCode: errorCode as TaskRunErrorCodes,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
runFriendlyId: mintItemRunFriendlyId(),
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
});
if (failedRunId) {
span.setAttribute("batch.result.pre_failed", true);
span.setAttribute("batch.result.run_id", failedRunId);
span.end();
return { success: true as const, runId: failedRunId };
}
}
// Fallback if TriggerFailedTaskService or environment lookup fails
span.end();
return { success: false as const, error: itemError, errorCode };
}
let environment: AuthenticatedEnvironment | undefined;
try {
environment = (await findEnvironmentById(meta.environmentId)) ?? undefined;
if (!environment) {
span.setAttribute("batch.result.error", "Environment not found");
span.end();
return {
success: false as const,
error: "Environment not found",
errorCode: "ENVIRONMENT_NOT_FOUND",
};
}
const triggerTaskService = new TriggerTaskService();
// Normalize payload - for application/store (R2 paths), this passes through as-is
const payload = normalizePayload(item.payload, item.payloadType);
const runFriendlyId = mintItemRunFriendlyId();
const result = await triggerTaskService.call(
item.task,
environment,
{
payload,
options: {
...(item.options as Record<string, unknown>),
payloadType: item.payloadType,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
parentBatch: batchId,
},
},
{
triggerVersion: meta.triggerVersion,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
batchId,
batchIndex: itemIndex,
runFriendlyId,
realtimeStreamsVersion: meta.realtimeStreamsVersion,
planType: meta.planType,
triggerSource: meta.parentRunId ? "sdk" : (meta.triggerSource ?? "api"),
triggerAction: "trigger",
},
"V2"
);
if (result) {
span.setAttribute("batch.result.run_id", result.run.friendlyId);
span.end();
return { success: true as const, runId: result.run.friendlyId };
} else {
logger.error("[BatchQueue] TriggerTaskService returned undefined", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
attempt,
isFinalAttempt,
});
span.setAttribute("batch.result.error", "TriggerTaskService returned undefined");
// Only create a pre-failed run on the final attempt; otherwise let the retry mechanism handle it
if (isFinalAttempt) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload,
payloadType: item.payloadType as string,
errorMessage: "TriggerTaskService returned undefined",
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
runFriendlyId: mintItemRunFriendlyId(),
options: item.options as Record<string, unknown>,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
});
span.end();
if (failedRunId) {
return { success: true as const, runId: failedRunId };
}
} else {
span.end();
}
return {
success: false as const,
error: "TriggerTaskService returned undefined",
errorCode: "TRIGGER_FAILED",
};
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
// Queue-size-limit rejections are a customer-overload scenario (the
// env's queue is at its configured max). Retrying is pointless — the
// same item will fail again — and creating pre-failed TaskRuns for
// every item of every retried batch is exactly what chews through
// DB capacity when a noisy tenant fills their queue. Signal the
// BatchQueue to skip retries and skip pre-failed run creation, and
// let the completion callback collapse the per-item errors into a
// single summary row.
if (error instanceof QueueSizeLimitExceededError) {
logger.warn("[BatchQueue] Batch item rejected: queue size limit reached", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
maximumSize: error.maximumSize,
});
span.setAttribute("batch.result.error", errorMessage);
span.setAttribute("batch.result.errorCode", QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE);
span.setAttribute("batch.result.skipRetries", true);
span.end();
return {
success: false as const,
error: errorMessage,
errorCode: QUEUE_SIZE_LIMIT_EXCEEDED_ERROR_CODE,
skipRetries: true,
};
}
logger.error("[BatchQueue] Failed to trigger batch item", {
batchId,
friendlyId,
itemIndex,
task: item.task,
environmentId: meta.environmentId,
attempt,
isFinalAttempt,
error,
});
span.setAttribute("batch.result.error", errorMessage);
span.recordException(error instanceof Error ? error : new Error(String(error)));
// Only create a pre-failed run on the final attempt; otherwise let the retry mechanism handle it
if (isFinalAttempt && environment) {
const failedRunId = await triggerFailedTaskService.call({
taskId: item.task,
environment,
payload: item.payload,
payloadType: item.payloadType as string,
errorMessage,
parentRunId: meta.parentRunId,
resumeParentOnCompletion: meta.resumeParentOnCompletion,
batch: { id: batchId, index: itemIndex },
runFriendlyId: mintItemRunFriendlyId(),
options: item.options as Record<string, unknown>,
traceContext: meta.traceContext as Record<string, unknown> | undefined,
spanParentAsLink: meta.spanParentAsLink,
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
});
span.end();
if (failedRunId) {
return { success: true as const, runId: failedRunId };
}
} else {
span.end();
}
return {
success: false as const,
error: errorMessage,
errorCode: "TRIGGER_ERROR",
};
}
}
);
}
);
// Batch completion callback - updates Postgres with results. The source callback
// is a thin wrapper that resolves the split-mode gate and supplies the run-ops
// handles; the body lives in handleBatchCompletion for testability.
engine.setBatchCompletionCallback(async (result: CompleteBatchResult) => {
await handleBatchCompletion(result, {
splitEnabled: await splitEnabledPromise,
newReplica: runOpsNewReplica,
newWriter: runOpsNewPrisma,
legacyWriter: runOpsLegacyPrisma,
tryCompleteBatch: (batchId) => engine.tryCompleteBatch({ batchId }),
});
});
logger.info("BatchQueue callbacks configured");
}
/**
* Normalize the payload from BatchQueue.
*
* Handles different payload types:
* - "application/store": Already offloaded to R2, payload is the path - pass through as-is
* - "application/json": May be a pre-serialized JSON string - parse to avoid double-stringification
* - Other types: Pass through as-is
*
* @param payload - The raw payload from the batch item
* @param payloadType - The payload type (e.g., "application/json", "application/store")
*/
function normalizePayload(payload: unknown, payloadType?: string): unknown {
// Only process "application/json" payloads
// For all other types (including undefined), return as-is
if (payloadType !== "application/json") {
return payload;
}
// For JSON payloads, if payload is a string, try to parse it
// This handles pre-serialized JSON from the SDK
if (typeof payload === "string") {
try {
return JSON.parse(payload);
} catch {
// If it's not valid JSON, return as-is
return payload;
}
}
return payload;
}