chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user