596 lines
20 KiB
TypeScript
596 lines
20 KiB
TypeScript
import { RunDuplicateIdempotencyKeyError, RunEngine } from "@internal/run-engine";
|
|
import {
|
|
IOPacket,
|
|
packetRequiresOffloading,
|
|
SemanticInternalAttributes,
|
|
TaskRunError,
|
|
taskRunErrorEnhancer,
|
|
taskRunErrorToString,
|
|
TriggerTaskRequestBody,
|
|
} from "@trigger.dev/core/v3";
|
|
import {
|
|
BatchId,
|
|
RunId,
|
|
sanitizeQueueName,
|
|
stringifyDuration,
|
|
} from "@trigger.dev/core/v3/isomorphic";
|
|
import { Prisma } from "@trigger.dev/database";
|
|
import { env } from "~/env.server";
|
|
import { createTags, MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
|
|
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
|
import { autoIncrementCounter } from "~/services/autoIncrementCounter.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { getEntitlement } from "~/services/platform.v3.server";
|
|
import { parseDelay } from "~/utils/delays";
|
|
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
|
import { handleMetadataPacket } from "~/utils/packets";
|
|
import { eventRepository } from "../../v3/eventRepository.server";
|
|
import { findCurrentWorkerFromEnvironment } from "../../v3/models/workerDeployment.server";
|
|
import { uploadPacketToObjectStore } from "../../v3/r2.server";
|
|
import { getTaskEventStore } from "../../v3/taskEventStore.server";
|
|
import { isFinalRunStatus } from "../../v3/taskStatus";
|
|
import { startActiveSpan } from "../../v3/tracer.server";
|
|
import { clampMaxDuration } from "../../v3/utils/maxDuration";
|
|
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
|
import {
|
|
MAX_ATTEMPTS,
|
|
OutOfEntitlementError,
|
|
TriggerTaskServiceOptions,
|
|
TriggerTaskServiceResult,
|
|
} from "../../v3/services/triggerTask.server";
|
|
import { WorkerGroupService } from "../../v3/services/worker/workerGroupService.server";
|
|
|
|
export class RunEngineTriggerTaskService extends WithRunEngine {
|
|
public async call({
|
|
taskId,
|
|
environment,
|
|
body,
|
|
options = {},
|
|
attempt = 0,
|
|
}: {
|
|
taskId: string;
|
|
environment: AuthenticatedEnvironment;
|
|
body: TriggerTaskRequestBody;
|
|
options?: TriggerTaskServiceOptions;
|
|
attempt?: number;
|
|
}): Promise<TriggerTaskServiceResult | undefined> {
|
|
return await this.traceWithEnv("call()", environment, async (span) => {
|
|
span.setAttribute("taskId", taskId);
|
|
span.setAttribute("attempt", attempt);
|
|
|
|
if (attempt > MAX_ATTEMPTS) {
|
|
throw new ServiceValidationError(
|
|
`Failed to trigger ${taskId} after ${MAX_ATTEMPTS} attempts.`
|
|
);
|
|
}
|
|
|
|
const idempotencyKey = options.idempotencyKey ?? body.options?.idempotencyKey;
|
|
const idempotencyKeyExpiresAt =
|
|
options.idempotencyKeyExpiresAt ??
|
|
resolveIdempotencyKeyTTL(body.options?.idempotencyKeyTTL) ??
|
|
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30); // 30 days
|
|
|
|
const delayUntil = await parseDelay(body.options?.delay);
|
|
|
|
const ttl =
|
|
typeof body.options?.ttl === "number"
|
|
? stringifyDuration(body.options?.ttl)
|
|
: body.options?.ttl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined);
|
|
|
|
const existingRun = idempotencyKey
|
|
? await this._prisma.taskRun.findFirst({
|
|
where: {
|
|
runtimeEnvironmentId: environment.id,
|
|
idempotencyKey,
|
|
taskIdentifier: taskId,
|
|
},
|
|
include: {
|
|
associatedWaitpoint: true,
|
|
},
|
|
})
|
|
: undefined;
|
|
|
|
if (existingRun) {
|
|
if (
|
|
existingRun.idempotencyKeyExpiresAt &&
|
|
existingRun.idempotencyKeyExpiresAt < new Date()
|
|
) {
|
|
logger.debug("[TriggerTaskService][call] Idempotency key has expired", {
|
|
idempotencyKey: options.idempotencyKey,
|
|
run: existingRun,
|
|
});
|
|
|
|
// Update the existing run to remove the idempotency key
|
|
await this._prisma.taskRun.update({
|
|
where: { id: existingRun.id },
|
|
data: { idempotencyKey: null },
|
|
});
|
|
} else {
|
|
span.setAttribute("runId", existingRun.friendlyId);
|
|
|
|
//We're using `andWait` so we need to block the parent run with a waitpoint
|
|
if (
|
|
existingRun.associatedWaitpoint &&
|
|
body.options?.resumeParentOnCompletion &&
|
|
body.options?.parentRunId
|
|
) {
|
|
await eventRepository.traceEvent(
|
|
`${taskId} (cached)`,
|
|
{
|
|
context: options.traceContext,
|
|
spanParentAsLink: options.spanParentAsLink,
|
|
parentAsLinkType: options.parentAsLinkType,
|
|
kind: "SERVER",
|
|
environment,
|
|
taskSlug: taskId,
|
|
attributes: {
|
|
properties: {
|
|
[SemanticInternalAttributes.SHOW_ACTIONS]: true,
|
|
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
|
|
},
|
|
style: {
|
|
icon: "task-cached",
|
|
},
|
|
runIsTest: body.options?.test ?? false,
|
|
batchId: options.batchId ? BatchId.toFriendlyId(options.batchId) : undefined,
|
|
idempotencyKey,
|
|
runId: existingRun.friendlyId,
|
|
},
|
|
incomplete: existingRun.associatedWaitpoint.status === "PENDING",
|
|
isError: existingRun.associatedWaitpoint.outputIsError,
|
|
immediate: true,
|
|
},
|
|
async (event) => {
|
|
//log a message
|
|
await eventRepository.recordEvent(
|
|
`There's an existing run for idempotencyKey: ${idempotencyKey}`,
|
|
{
|
|
taskSlug: taskId,
|
|
environment,
|
|
attributes: {
|
|
runId: existingRun.friendlyId,
|
|
},
|
|
context: options.traceContext,
|
|
parentId: event.spanId,
|
|
}
|
|
);
|
|
//block run with waitpoint
|
|
await this._engine.blockRunWithWaitpoint({
|
|
runId: RunId.fromFriendlyId(body.options!.parentRunId!),
|
|
waitpoints: existingRun.associatedWaitpoint!.id,
|
|
spanIdToComplete: event.spanId,
|
|
batch: options?.batchId
|
|
? {
|
|
id: options.batchId,
|
|
index: options.batchIndex ?? 0,
|
|
}
|
|
: undefined,
|
|
projectId: environment.projectId,
|
|
organizationId: environment.organizationId,
|
|
tx: this._prisma,
|
|
releaseConcurrency: body.options?.releaseConcurrency,
|
|
});
|
|
}
|
|
);
|
|
}
|
|
|
|
return { run: existingRun, isCached: true };
|
|
}
|
|
}
|
|
|
|
if (environment.type !== "DEVELOPMENT") {
|
|
const result = await getEntitlement(environment.organizationId);
|
|
if (result && result.hasAccess === false) {
|
|
throw new OutOfEntitlementError();
|
|
}
|
|
}
|
|
|
|
if (!options.skipChecks) {
|
|
const queueSizeGuard = await guardQueueSizeLimitsForEnv(this._engine, environment);
|
|
|
|
logger.debug("Queue size guard result", {
|
|
queueSizeGuard,
|
|
environment: {
|
|
id: environment.id,
|
|
type: environment.type,
|
|
organization: environment.organization,
|
|
project: environment.project,
|
|
},
|
|
});
|
|
|
|
if (!queueSizeGuard.isWithinLimits) {
|
|
throw new ServiceValidationError(
|
|
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`
|
|
);
|
|
}
|
|
}
|
|
|
|
if (
|
|
body.options?.tags &&
|
|
typeof body.options.tags !== "string" &&
|
|
body.options.tags.length > MAX_TAGS_PER_RUN
|
|
) {
|
|
throw new ServiceValidationError(
|
|
`Runs can only have ${MAX_TAGS_PER_RUN} tags, you're trying to set ${body.options.tags.length}.`
|
|
);
|
|
}
|
|
|
|
const runFriendlyId = options?.runFriendlyId ?? RunId.generate().friendlyId;
|
|
|
|
const payloadPacket = await this.#handlePayloadPacket(
|
|
body.payload,
|
|
body.options?.payloadType ?? "application/json",
|
|
runFriendlyId,
|
|
environment
|
|
);
|
|
|
|
const metadataPacket = body.options?.metadata
|
|
? handleMetadataPacket(
|
|
body.options?.metadata,
|
|
body.options?.metadataType ?? "application/json"
|
|
)
|
|
: undefined;
|
|
|
|
const parentRun = body.options?.parentRunId
|
|
? await this._prisma.taskRun.findFirst({
|
|
where: { id: RunId.fromFriendlyId(body.options.parentRunId) },
|
|
})
|
|
: undefined;
|
|
|
|
if (parentRun && isFinalRunStatus(parentRun.status)) {
|
|
logger.debug("Parent run is in a terminal state", {
|
|
parentRun,
|
|
});
|
|
|
|
throw new ServiceValidationError(
|
|
`Cannot trigger ${taskId} as the parent run has a status of ${parentRun.status}`
|
|
);
|
|
}
|
|
|
|
try {
|
|
return await eventRepository.traceEvent(
|
|
taskId,
|
|
{
|
|
context: options.traceContext,
|
|
spanParentAsLink: options.spanParentAsLink,
|
|
parentAsLinkType: options.parentAsLinkType,
|
|
kind: "SERVER",
|
|
environment,
|
|
taskSlug: taskId,
|
|
attributes: {
|
|
properties: {
|
|
[SemanticInternalAttributes.SHOW_ACTIONS]: true,
|
|
},
|
|
style: {
|
|
icon: options.customIcon ?? "task",
|
|
},
|
|
runIsTest: body.options?.test ?? false,
|
|
batchId: options.batchId ? BatchId.toFriendlyId(options.batchId) : undefined,
|
|
idempotencyKey,
|
|
},
|
|
incomplete: true,
|
|
immediate: true,
|
|
},
|
|
async (event, traceContext, traceparent) => {
|
|
const result = await autoIncrementCounter.incrementInTransaction(
|
|
`v3-run:${environment.id}:${taskId}`,
|
|
async (num, tx) => {
|
|
const lockedToBackgroundWorker = body.options?.lockToVersion
|
|
? await tx.backgroundWorker.findFirst({
|
|
where: {
|
|
projectId: environment.projectId,
|
|
runtimeEnvironmentId: environment.id,
|
|
version: body.options?.lockToVersion,
|
|
},
|
|
select: {
|
|
id: true,
|
|
version: true,
|
|
sdkVersion: true,
|
|
cliVersion: true,
|
|
},
|
|
})
|
|
: undefined;
|
|
|
|
let queueName = sanitizeQueueName(
|
|
await this.#getQueueName(taskId, environment, body.options?.queue?.name)
|
|
);
|
|
|
|
// Check that the queuename is not an empty string
|
|
if (!queueName) {
|
|
queueName = sanitizeQueueName(`task/${taskId}`);
|
|
}
|
|
|
|
event.setAttribute("queueName", queueName);
|
|
span.setAttribute("queueName", queueName);
|
|
|
|
//upsert tags
|
|
const tags = await createTags(
|
|
{
|
|
tags: body.options?.tags,
|
|
projectId: environment.projectId,
|
|
},
|
|
this._prisma
|
|
);
|
|
|
|
const depth = parentRun ? parentRun.depth + 1 : 0;
|
|
|
|
event.setAttribute("runId", runFriendlyId);
|
|
span.setAttribute("runId", runFriendlyId);
|
|
|
|
const masterQueue = await this.#getMasterQueueForEnvironment(environment);
|
|
|
|
const taskRun = await this._engine.trigger(
|
|
{
|
|
number: num,
|
|
friendlyId: runFriendlyId,
|
|
environment: environment,
|
|
idempotencyKey,
|
|
idempotencyKeyExpiresAt: idempotencyKey ? idempotencyKeyExpiresAt : undefined,
|
|
taskIdentifier: taskId,
|
|
payload: payloadPacket.data ?? "",
|
|
payloadType: payloadPacket.dataType,
|
|
context: body.context,
|
|
traceContext: traceContext,
|
|
traceId: event.traceId,
|
|
spanId: event.spanId,
|
|
parentSpanId:
|
|
options.parentAsLinkType === "replay" ? undefined : traceparent?.spanId,
|
|
lockedToVersionId: lockedToBackgroundWorker?.id,
|
|
taskVersion: lockedToBackgroundWorker?.version,
|
|
sdkVersion: lockedToBackgroundWorker?.sdkVersion,
|
|
cliVersion: lockedToBackgroundWorker?.cliVersion,
|
|
concurrencyKey: body.options?.concurrencyKey,
|
|
queue: queueName,
|
|
masterQueue: masterQueue,
|
|
isTest: body.options?.test ?? false,
|
|
delayUntil,
|
|
queuedAt: delayUntil ? undefined : new Date(),
|
|
maxAttempts: body.options?.maxAttempts,
|
|
taskEventStore: getTaskEventStore(),
|
|
ttl,
|
|
tags,
|
|
oneTimeUseToken: options.oneTimeUseToken,
|
|
parentTaskRunId: parentRun?.id,
|
|
rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id,
|
|
batch: options?.batchId
|
|
? {
|
|
id: options.batchId,
|
|
index: options.batchIndex ?? 0,
|
|
}
|
|
: undefined,
|
|
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
|
depth,
|
|
metadata: metadataPacket?.data,
|
|
metadataType: metadataPacket?.dataType,
|
|
seedMetadata: metadataPacket?.data,
|
|
seedMetadataType: metadataPacket?.dataType,
|
|
maxDurationInSeconds: body.options?.maxDuration
|
|
? clampMaxDuration(body.options.maxDuration)
|
|
: undefined,
|
|
machine: body.options?.machine,
|
|
priorityMs: body.options?.priority ? body.options.priority * 1_000 : undefined,
|
|
releaseConcurrency: body.options?.releaseConcurrency,
|
|
queueTimestamp:
|
|
parentRun && body.options?.resumeParentOnCompletion
|
|
? parentRun.queueTimestamp ?? undefined
|
|
: undefined,
|
|
},
|
|
this._prisma
|
|
);
|
|
|
|
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
|
|
|
if (error) {
|
|
event.failWithError(error);
|
|
}
|
|
|
|
return { run: taskRun, error, isCached: false };
|
|
},
|
|
async (_, tx) => {
|
|
const counter = await tx.taskRunNumberCounter.findFirst({
|
|
where: {
|
|
taskIdentifier: taskId,
|
|
environmentId: environment.id,
|
|
},
|
|
select: { lastNumber: true },
|
|
});
|
|
|
|
return counter?.lastNumber;
|
|
},
|
|
this._prisma
|
|
);
|
|
|
|
if (result?.error) {
|
|
throw new ServiceValidationError(
|
|
taskRunErrorToString(taskRunErrorEnhancer(result.error))
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
);
|
|
} catch (error) {
|
|
if (error instanceof RunDuplicateIdempotencyKeyError) {
|
|
//retry calling this function, because this time it will return the idempotent run
|
|
return await this.call({ taskId, environment, body, options, attempt: attempt + 1 });
|
|
}
|
|
|
|
// Detect a prisma transaction Unique constraint violation
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
logger.debug("TriggerTask: 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 trigger ${taskId} with a one-time use token as it has already been used.`
|
|
);
|
|
} else {
|
|
throw new ServiceValidationError(
|
|
`Cannot trigger ${taskId} as it has already been triggered with the same idempotency key.`
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
});
|
|
}
|
|
|
|
async #getMasterQueueForEnvironment(environment: AuthenticatedEnvironment) {
|
|
if (environment.type === "DEVELOPMENT") {
|
|
return;
|
|
}
|
|
|
|
const workerGroupService = new WorkerGroupService({
|
|
prisma: this._prisma,
|
|
engine: this._engine,
|
|
});
|
|
|
|
const workerGroup = await workerGroupService.getDefaultWorkerGroupForProject({
|
|
projectId: environment.projectId,
|
|
});
|
|
|
|
if (!workerGroup) {
|
|
throw new ServiceValidationError("No worker group found");
|
|
}
|
|
|
|
return workerGroup.masterQueue;
|
|
}
|
|
|
|
async #getQueueName(taskId: string, environment: AuthenticatedEnvironment, queueName?: string) {
|
|
if (queueName) {
|
|
return queueName;
|
|
}
|
|
|
|
const defaultQueueName = `task/${taskId}`;
|
|
|
|
const worker = await findCurrentWorkerFromEnvironment(environment);
|
|
|
|
if (!worker) {
|
|
logger.debug("Failed to get queue name: No worker found", {
|
|
taskId,
|
|
environmentId: environment.id,
|
|
});
|
|
|
|
return defaultQueueName;
|
|
}
|
|
|
|
const task = await this._prisma.backgroundWorkerTask.findFirst({
|
|
where: {
|
|
workerId: worker.id,
|
|
slug: taskId,
|
|
},
|
|
include: {
|
|
queue: true,
|
|
},
|
|
});
|
|
|
|
if (!task) {
|
|
console.log("Failed to get queue name: No task found", {
|
|
taskId,
|
|
environmentId: environment.id,
|
|
});
|
|
|
|
return defaultQueueName;
|
|
}
|
|
|
|
if (!task.queue) {
|
|
console.log("Failed to get queue name: No queue found", {
|
|
taskId,
|
|
environmentId: environment.id,
|
|
queueConfig: task.queueConfig,
|
|
});
|
|
|
|
return defaultQueueName;
|
|
}
|
|
|
|
return task.queue.name ?? defaultQueueName;
|
|
}
|
|
|
|
async #handlePayloadPacket(
|
|
payload: any,
|
|
payloadType: string,
|
|
pathPrefix: string,
|
|
environment: AuthenticatedEnvironment
|
|
) {
|
|
return await startActiveSpan("handlePayloadPacket()", async (span) => {
|
|
const packet = this.#createPayloadPacket(payload, payloadType);
|
|
|
|
if (!packet.data) {
|
|
return packet;
|
|
}
|
|
|
|
const { needsOffloading, size } = packetRequiresOffloading(
|
|
packet,
|
|
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
|
|
);
|
|
|
|
if (!needsOffloading) {
|
|
return packet;
|
|
}
|
|
|
|
const filename = `${pathPrefix}/payload.json`;
|
|
|
|
await uploadPacketToObjectStore(filename, packet.data, packet.dataType, environment);
|
|
|
|
return {
|
|
data: filename,
|
|
dataType: "application/store",
|
|
};
|
|
});
|
|
}
|
|
|
|
#createPayloadPacket(payload: any, payloadType: string): IOPacket {
|
|
if (payloadType === "application/json") {
|
|
return { data: JSON.stringify(payload), dataType: "application/json" };
|
|
}
|
|
|
|
if (typeof payload === "string") {
|
|
return { data: payload, dataType: payloadType };
|
|
}
|
|
|
|
return { dataType: payloadType };
|
|
}
|
|
}
|
|
|
|
function getMaximumSizeForEnvironment(environment: AuthenticatedEnvironment): number | undefined {
|
|
if (environment.type === "DEVELOPMENT") {
|
|
return environment.organization.maximumDevQueueSize ?? env.MAXIMUM_DEV_QUEUE_SIZE;
|
|
} else {
|
|
return environment.organization.maximumDeployedQueueSize ?? env.MAXIMUM_DEPLOYED_QUEUE_SIZE;
|
|
}
|
|
}
|
|
|
|
export async function guardQueueSizeLimitsForEnv(
|
|
engine: RunEngine,
|
|
environment: AuthenticatedEnvironment,
|
|
itemsToAdd: number = 1
|
|
) {
|
|
const maximumSize = getMaximumSizeForEnvironment(environment);
|
|
|
|
if (typeof maximumSize === "undefined") {
|
|
return { isWithinLimits: true };
|
|
}
|
|
|
|
const queueSize = await engine.lengthOfEnvQueue(environment);
|
|
const projectedSize = queueSize + itemsToAdd;
|
|
|
|
return {
|
|
isWithinLimits: projectedSize <= maximumSize,
|
|
maximumSize,
|
|
queueSize,
|
|
};
|
|
}
|