Files
2026-07-13 13:32:57 +08:00

436 lines
18 KiB
TypeScript

import type { RunEngine } from "@internal/run-engine";
import { TaskRunErrorCodes, type TaskRunError } from "@trigger.dev/core/v3";
import { RunId, generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
import type {
PrismaClientOrTransaction,
RuntimeEnvironmentType,
TaskRun,
} from "@trigger.dev/database";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
import { resolveInheritedMintKind } from "~/v3/runOpsMigration/resolveInheritedMintKind.server";
import { getEventRepository } from "~/v3/eventRepository/index.server";
import { runStore as defaultRunStore } from "~/v3/runStore.server";
import type { RunStore } from "@internal/run-store";
import type { IEventRepository } from "~/v3/eventRepository/eventRepository.types";
import { PerformTaskRunAlertsService } from "~/v3/services/alerts/performTaskRunAlerts.server";
import { DefaultQueueManager } from "../concerns/queues.server";
import type { TriggerTaskRequest } from "../types";
export type TriggerFailedTaskRequest = {
/** The task identifier (e.g. "my-task") */
taskId: string;
/** The fully-resolved authenticated environment */
environment: AuthenticatedEnvironment;
/** Raw payload — string or object */
payload: unknown;
/** MIME type of the payload (defaults to "application/json") */
payloadType?: string;
/** Error message describing why the run failed */
errorMessage: string;
/** Parent run friendly ID (e.g. "run_xxxx") */
parentRunId?: string;
/** Whether completing this run should resume the parent */
resumeParentOnCompletion?: boolean;
/** Batch association */
batch?: { id: string; index: number };
/** Trigger options from the original request (queue config, etc.) */
options?: Record<string, unknown>;
/** Trace context for span correlation */
traceContext?: Record<string, unknown>;
/** Whether the span parent should be treated as a link rather than a parent */
spanParentAsLink?: boolean;
errorCode?: TaskRunErrorCodes;
/** Pre-minted friendlyId; when set it wins over the mint. Batch callers pass a batch-anchored id. */
runFriendlyId?: string;
};
/**
* Creates a pre-failed TaskRun with a trace event span.
*
* This is used when a task cannot be triggered (e.g. queue limit reached, validation
* error, etc.) but we still need to record the failure so that:
* - Batch completion can track the item
* - Parent runs get unblocked
* - The failed run shows up in the run logs view
*
* This service resolves the parent run (for rootTaskRunId/depth) and queue properties
* the same way triggerTask does, so the run is correctly associated in the task tree
* and the SpanPresenter can find the TaskQueue.
*/
export class TriggerFailedTaskService {
private readonly prisma: PrismaClientOrTransaction;
private readonly replicaPrisma: PrismaClientOrTransaction;
private readonly engine: RunEngine;
// Resolves the parent run for depth/root/parent linkage. Defaults to the shared
// singleton (in production the same store the engine writes through). Injected in
// tests so the read resolves on the same store the engine wrote to.
private readonly runStore: RunStore;
// Defaults to getEventRepository's org-flag resolution, which reads through the
// global prisma client; tests inject a repository bound to their testcontainer DB.
private readonly eventRepository?: { repository: IEventRepository; store: string };
constructor(opts: {
prisma: PrismaClientOrTransaction;
engine: RunEngine;
replicaPrisma?: PrismaClientOrTransaction;
runStore?: RunStore;
eventRepository?: { repository: IEventRepository; store: string };
}) {
this.prisma = opts.prisma;
this.replicaPrisma = opts.replicaPrisma ?? opts.prisma;
this.engine = opts.engine;
this.runStore = opts.runStore ?? defaultRunStore;
this.eventRepository = opts.eventRepository;
}
// Mint a failed run's friendlyId. The id-kind decides which store the run is
// born in (cuid → legacy store, run-ops id → new store); the whole subgraph of a
// run must agree. A caller-supplied runFriendlyId (batch-anchored id) wins verbatim;
// otherwise root failed runs mint by the environment's setting and child failed runs
// inherit the parent's current store so they never split.
private async mintFailedRunFriendlyId(args: {
organizationId: string;
environmentId: string;
orgFeatureFlags?: unknown;
parentRunFriendlyId?: string;
runFriendlyId?: string;
}): Promise<string> {
if (args.runFriendlyId) {
return args.runFriendlyId;
}
const mintKind = args.parentRunFriendlyId
? resolveInheritedMintKind(args.parentRunFriendlyId)
: await resolveRunIdMintKind({
organizationId: args.organizationId,
id: args.environmentId,
orgFeatureFlags: args.orgFeatureFlags,
});
return mintKind === "runOpsId"
? RunId.toFriendlyId(generateRunOpsId())
: RunId.generate().friendlyId;
}
async call(request: TriggerFailedTaskRequest): Promise<string | null> {
const taskRunError: TaskRunError = {
type: "INTERNAL_ERROR" as const,
code: request.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
message: request.errorMessage,
};
// Held for the catch's log line; the in-try `const` is what consumers use.
let mintedFriendlyId: string | undefined;
try {
// Mint inside the try: classifying a user-supplied parentRunId throws on
// an unclassifiable id, so keep it within the catch's null-return contract.
const failedRunFriendlyId = await this.mintFailedRunFriendlyId({
organizationId: request.environment.organizationId,
environmentId: request.environment.id,
orgFeatureFlags: request.environment.organization.featureFlags,
parentRunFriendlyId: request.parentRunId,
runFriendlyId: request.runFriendlyId,
});
mintedFriendlyId = failedRunFriendlyId;
const { repository, store } =
this.eventRepository ??
(await getEventRepository(
request.environment.organization.id,
request.environment.organization.featureFlags as Record<string, unknown>,
undefined
));
// Resolve parent run for rootTaskRunId and depth (same as triggerTask.server.ts)
const parentRun = request.parentRunId
? await this.runStore.findRun(
{
id: RunId.fromFriendlyId(request.parentRunId),
runtimeEnvironmentId: request.environment.id,
},
this.prisma
)
: undefined;
const depth = parentRun ? parentRun.depth + 1 : 0;
const rootTaskRunId = parentRun?.rootTaskRunId ?? parentRun?.id;
// Resolve queue properties (same as triggerTask) so span presenter can find TaskQueue.
// Best-effort: if resolution throws (e.g. request shape, missing worker), we still create
// the run without queue/lockedQueueId so run creation and trace events never regress.
let queueName: string | undefined;
let lockedQueueId: string | undefined;
try {
const queueConcern = new DefaultQueueManager(this.prisma, this.engine, this.replicaPrisma);
const bodyOptions = request.options as TriggerTaskRequest["body"]["options"];
const triggerRequest: TriggerTaskRequest = {
taskId: request.taskId,
friendlyId: failedRunFriendlyId,
environment: request.environment,
body: {
payload:
typeof request.payload === "string"
? request.payload
: JSON.stringify(request.payload ?? {}),
options: bodyOptions,
},
};
// Resolve the locked background worker if lockToVersion is set (same as triggerTask).
// resolveQueueProperties requires the worker to be passed when lockToVersion is present.
const lockedToBackgroundWorker = bodyOptions?.lockToVersion
? await this.prisma.backgroundWorker.findFirst({
where: {
projectId: request.environment.projectId,
runtimeEnvironmentId: request.environment.id,
version: bodyOptions.lockToVersion,
},
select: {
id: true,
version: true,
sdkVersion: true,
cliVersion: true,
},
})
: undefined;
const resolved = await queueConcern.resolveQueueProperties(
triggerRequest,
lockedToBackgroundWorker ?? undefined
);
queueName = resolved.queueName;
lockedQueueId = resolved.lockedQueueId;
} catch (queueResolveError) {
const err =
queueResolveError instanceof Error
? queueResolveError
: new Error(String(queueResolveError));
logger.warn("TriggerFailedTaskService: queue resolution failed, using defaults", {
taskId: request.taskId,
friendlyId: failedRunFriendlyId,
error: err.message,
});
}
// Create the failed run inside a trace event span so it shows up in run logs
const failedRun: TaskRun = await repository.traceEvent(
request.taskId,
{
context: request.traceContext,
spanParentAsLink: request.spanParentAsLink,
kind: "SERVER",
environment: {
id: request.environment.id,
type: request.environment.type,
organizationId: request.environment.organizationId,
projectId: request.environment.projectId,
project: { externalRef: request.environment.project.externalRef },
},
taskSlug: request.taskId,
attributes: {
properties: {},
style: { icon: "task" },
},
incomplete: false,
isError: true,
immediate: true,
},
async (event, traceContext) => {
event.setAttribute("runId", failedRunFriendlyId);
event.failWithError(taskRunError);
// `emitRunFailedEvent: false` because this call site owns the
// trace-event lifecycle via the outer `traceEvent({
// incomplete: false, isError: true })`. Letting the engine
// emit `runFailed` here would race the
// `completeFailedRunEvent` listener against the outer trace
// event's own completion write for the same (traceId, spanId).
// We re-trigger the alerts side directly after the trace
// event closes, below.
return await this.engine.createFailedTaskRun({
friendlyId: failedRunFriendlyId,
environment: {
id: request.environment.id,
type: request.environment.type,
project: { id: request.environment.project.id },
organization: { id: request.environment.organization.id },
},
taskIdentifier: request.taskId,
payload:
typeof request.payload === "string"
? request.payload
: JSON.stringify(request.payload ?? ""),
payloadType: request.payloadType ?? "application/json",
error: taskRunError,
parentTaskRunId: parentRun?.id,
rootTaskRunId,
depth,
resumeParentOnCompletion: request.resumeParentOnCompletion,
batch: request.batch,
traceId: event.traceId,
spanId: event.spanId,
traceContext: traceContext as Record<string, unknown>,
taskEventStore: store,
emitRunFailedEvent: false,
...(queueName !== undefined && { queue: queueName }),
...(lockedQueueId !== undefined && { lockedQueueId }),
});
}
);
// Alerts side of `runFailed` — the engine emit was suppressed
// above so the trace-event completion isn't double-written; we
// still need the alert pipeline to fire so customers' ERROR
// channels see the failure. Best-effort: a failed enqueue logs
// but doesn't block returning the friendlyId, mirroring the
// engine handler's behaviour at runEngineHandlers.server.ts:81.
try {
await PerformTaskRunAlertsService.enqueue(failedRun.id);
} catch (alertsError) {
logger.warn("TriggerFailedTaskService: alert enqueue failed", {
taskId: request.taskId,
friendlyId: failedRun.friendlyId,
error: alertsError instanceof Error ? alertsError.message : String(alertsError),
});
}
return failedRun.friendlyId;
} catch (createError) {
const createErrorMsg =
createError instanceof Error ? createError.message : String(createError);
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun", {
taskId: request.taskId,
friendlyId: mintedFriendlyId,
originalError: request.errorMessage,
createError: createErrorMsg,
});
return null;
}
}
/**
* Creates a pre-failed run without trace events.
* Used when the environment can't be fully resolved (e.g. environment not found)
* and we can't create trace events or look up parent runs.
*/
async callWithoutTraceEvents(opts: {
environmentId: string;
environmentType: RuntimeEnvironmentType;
projectId: string;
organizationId: string;
taskId: string;
payload: unknown;
payloadType?: string;
errorMessage: string;
parentRunId?: string;
resumeParentOnCompletion?: boolean;
batch?: { id: string; index: number };
errorCode?: TaskRunErrorCodes;
/** Pre-minted friendlyId; when set it wins over the mint. Batch callers pass a batch-anchored id. */
runFriendlyId?: string;
}): Promise<string | null> {
// Held for the catch's log line; the in-try `const` is what consumers use.
let mintedFriendlyId: string | undefined;
try {
// Mint inside the try: classifying a user-supplied parentRunId throws on
// an unclassifiable id, so keep it within the catch's null-return contract.
const failedRunFriendlyId = await this.mintFailedRunFriendlyId({
organizationId: opts.organizationId,
environmentId: opts.environmentId,
// No loaded org flags in this path; resolveRunIdMintKind falls back to a
// single replica lookup by organizationId only when there is no parent.
orgFeatureFlags: undefined,
parentRunFriendlyId: opts.parentRunId,
runFriendlyId: opts.runFriendlyId,
});
mintedFriendlyId = failedRunFriendlyId;
// Best-effort parent run lookup for rootTaskRunId/depth
let parentTaskRunId: string | undefined;
let rootTaskRunId: string | undefined;
let depth = 0;
if (opts.parentRunId) {
const parentRun = await this.runStore.findRun(
{
id: RunId.fromFriendlyId(opts.parentRunId),
runtimeEnvironmentId: opts.environmentId,
},
this.prisma
);
if (parentRun) {
parentTaskRunId = parentRun.id;
rootTaskRunId = parentRun.rootTaskRunId ?? parentRun.id;
depth = parentRun.depth + 1;
} else {
parentTaskRunId = RunId.fromFriendlyId(opts.parentRunId);
}
}
const failedRun = await this.engine.createFailedTaskRun({
friendlyId: failedRunFriendlyId,
environment: {
id: opts.environmentId,
type: opts.environmentType,
project: { id: opts.projectId },
organization: { id: opts.organizationId },
},
taskIdentifier: opts.taskId,
payload:
typeof opts.payload === "string" ? opts.payload : JSON.stringify(opts.payload ?? ""),
payloadType: opts.payloadType ?? "application/json",
error: {
type: "INTERNAL_ERROR" as const,
code: opts.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
message: opts.errorMessage,
},
parentTaskRunId,
rootTaskRunId,
depth,
resumeParentOnCompletion: opts.resumeParentOnCompletion,
batch: opts.batch,
// Suppress the engine's `runFailed` bus emit — the listener
// (`runEngineHandlers.server.ts` `runFailed`) calls
// `completeFailedRunEvent`, which writes a ClickHouse trace event
// row keyed on (traceId, spanId). This caller has no trace
// context (the method name is literally `callWithoutTraceEvents`)
// so the emit would write a row with empty traceId/spanId —
// orphan event in the store. We still want alert coverage,
// though, so enqueue directly below.
emitRunFailedEvent: false,
});
// Alerts side of `runFailed` — the engine emit was suppressed
// above so we don't create an orphan trace event; enqueue the
// alert directly so customers' ERROR channels still see the
// failure. Best-effort, mirroring the `call()` path.
try {
await PerformTaskRunAlertsService.enqueue(failedRun.id);
} catch (alertsError) {
logger.warn("TriggerFailedTaskService.callWithoutTraceEvents: alert enqueue failed", {
taskId: opts.taskId,
friendlyId: failedRun.friendlyId,
error: alertsError instanceof Error ? alertsError.message : String(alertsError),
});
}
return failedRunFriendlyId;
} catch (createError) {
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun (no trace)", {
taskId: opts.taskId,
friendlyId: mintedFriendlyId,
originalError: opts.errorMessage,
createError: createError instanceof Error ? createError.message : String(createError),
});
return null;
}
}
}