Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/services/completeAttempt.server.ts
Matt Aitken 7746302b51 Run failed alerts (#1262)
* Add new ProjectAlertType ‘TASK_RUN’ and then migrate channels to it

* WIP new alerts for run failures

* Consolidate failed run status into taskStatus.ts

* Added the read replica into BaseService, could be useful

* Send task run alerts code

* Revert "Added the read replica into BaseService, could be useful"

This reverts commit cc2348a40254ea3f9e11c0feaf303ecd8794433a.

* Allow adding task run alerts in the UI

* Use task run alert not attempt alert…

* Use the primary
2024-08-21 14:53:48 +01:00

465 lines
14 KiB
TypeScript

import { Attributes } from "@opentelemetry/api";
import {
TaskRunContext,
TaskRunExecution,
TaskRunExecutionResult,
TaskRunExecutionRetry,
TaskRunFailedExecutionResult,
TaskRunSuccessfulExecutionResult,
flattenAttributes,
sanitizeError,
} from "@trigger.dev/core/v3";
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
import { safeJsonParse } from "~/utils/json";
import { createExceptionPropertiesFromError, eventRepository } from "../eventRepository.server";
import { marqs } from "~/v3/marqs/index.server";
import { BaseService } from "./baseService.server";
import { CancelAttemptService } from "./cancelAttempt.server";
import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.server";
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
import { CreateCheckpointService } from "./createCheckpoint.server";
import { TaskRun } from "@trigger.dev/database";
import { RetryAttemptService } from "./retryAttempt.server";
import { isFinalAttemptStatus, isFinalRunStatus } from "../taskStatus";
import { FinalizeTaskRunService } from "./finalizeTaskRun.server";
import { env } from "~/env.server";
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
type CheckpointData = {
docker: boolean;
location: string;
};
export class CompleteAttemptService extends BaseService {
public async call({
completion,
execution,
env,
checkpoint,
supportsRetryCheckpoints,
}: {
completion: TaskRunExecutionResult;
execution: TaskRunExecution;
env?: AuthenticatedEnvironment;
checkpoint?: CheckpointData;
supportsRetryCheckpoints?: boolean;
}): Promise<"COMPLETED" | "RETRIED"> {
const taskRunAttempt = await findAttempt(this._prisma, execution.attempt.id);
if (!taskRunAttempt) {
logger.error("[CompleteAttemptService] Task run attempt not found", {
id: execution.attempt.id,
});
const run = await this._prisma.taskRun.findFirst({
where: {
friendlyId: execution.run.id,
},
select: {
id: true,
},
});
if (!run) {
logger.error("[CompleteAttemptService] Task run not found", {
friendlyId: execution.run.id,
});
return "COMPLETED";
}
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: run.id,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
});
// No attempt, so there's no message to ACK
return "COMPLETED";
}
if (
isFinalAttemptStatus(taskRunAttempt.status) ||
isFinalRunStatus(taskRunAttempt.taskRun.status)
) {
// We don't want to retry a task run that has already been marked as failed, cancelled, or completed
logger.debug("[CompleteAttemptService] Attempt or run is already in a final state", {
taskRunAttempt,
completion,
});
return "COMPLETED";
}
if (completion.ok) {
return await this.#completeAttemptSuccessfully(completion, taskRunAttempt, env);
} else {
return await this.#completeAttemptFailed({
completion,
execution,
taskRunAttempt,
env,
checkpoint,
supportsRetryCheckpoints,
});
}
}
async #completeAttemptSuccessfully(
completion: TaskRunSuccessfulExecutionResult,
taskRunAttempt: NonNullable<FoundAttempt>,
env?: AuthenticatedEnvironment
): Promise<"COMPLETED"> {
await $transaction(this._prisma, async (tx) => {
await tx.taskRunAttempt.update({
where: { id: taskRunAttempt.id },
data: {
status: "COMPLETED",
completedAt: new Date(),
output: completion.output,
outputType: completion.outputType,
usageDurationMs: completion.usage?.durationMs,
},
});
const finalizeService = new FinalizeTaskRunService(tx);
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "COMPLETED_SUCCESSFULLY",
completedAt: new Date(),
});
});
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: false,
output:
completion.outputType === "application/store" || completion.outputType === "text/plain"
? completion.output
: completion.output
? (safeJsonParse(completion.output) as Attributes)
: undefined,
outputType: completion.outputType,
},
});
if (!env || env.type !== "DEVELOPMENT") {
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
}
return "COMPLETED";
}
async #completeAttemptFailed({
completion,
execution,
taskRunAttempt,
env,
checkpoint,
supportsRetryCheckpoints,
}: {
completion: TaskRunFailedExecutionResult;
execution: TaskRunExecution;
taskRunAttempt: NonNullable<FoundAttempt>;
env?: AuthenticatedEnvironment;
checkpoint?: CheckpointData;
supportsRetryCheckpoints?: boolean;
}): Promise<"COMPLETED" | "RETRIED"> {
if (
completion.error.type === "INTERNAL_ERROR" &&
completion.error.code === "TASK_RUN_CANCELLED"
) {
// We need to cancel the task run instead of fail it
const cancelService = new CancelAttemptService();
// TODO: handle usages
await cancelService.call(
taskRunAttempt.friendlyId,
taskRunAttempt.taskRunId,
new Date(),
"Cancelled by user",
env
);
// The cancel service handles ACK
return "COMPLETED";
}
const sanitizedError = sanitizeError(completion.error);
await this._prisma.taskRunAttempt.update({
where: { id: taskRunAttempt.id },
data: {
status: "FAILED",
completedAt: new Date(),
error: sanitizedError,
usageDurationMs: completion.usage?.durationMs,
},
});
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
const retryAt = new Date(completion.retry.timestamp);
// Retry the task run
await eventRepository.recordEvent(`Retry #${execution.attempt.number} delay`, {
taskSlug: taskRunAttempt.taskRun.taskIdentifier,
environment,
attributes: {
metadata: this.#generateMetadataAttributesForNextAttempt(execution),
properties: {
retryAt: retryAt.toISOString(),
},
runId: taskRunAttempt.taskRun.friendlyId,
style: {
icon: "schedule-attempt",
},
queueId: taskRunAttempt.queueId,
queueName: taskRunAttempt.taskRun.queue,
},
context: taskRunAttempt.taskRun.traceContext as Record<string, string | undefined>,
spanIdSeed: `retry-${taskRunAttempt.number + 1}`,
endTime: retryAt,
});
logger.debug("Retrying", {
taskRun: taskRunAttempt.taskRun.friendlyId,
retry: completion.retry,
});
await this._prisma.taskRun.update({
where: {
id: taskRunAttempt.taskRunId,
},
data: {
status: "RETRYING_AFTER_FAILURE",
},
});
if (environment.type === "DEVELOPMENT") {
// This is already an EXECUTE message so we can just NACK
await marqs?.nackMessage(taskRunAttempt.taskRunId, completion.retry.timestamp);
return "RETRIED";
}
if (!checkpoint) {
await this.#retryAttempt({
run: taskRunAttempt.taskRun,
retry: completion.retry,
supportsLazyAttempts: taskRunAttempt.backgroundWorker.supportsLazyAttempts,
supportsRetryCheckpoints,
});
return "RETRIED";
}
const createCheckpoint = new CreateCheckpointService(this._prisma);
const checkpointCreateResult = await createCheckpoint.call({
attemptFriendlyId: execution.attempt.id,
docker: checkpoint.docker,
location: checkpoint.location,
reason: {
type: "RETRYING_AFTER_FAILURE",
attemptNumber: execution.attempt.number,
},
});
if (!checkpointCreateResult.success) {
logger.error("Failed to create checkpoint", { checkpoint, execution: execution.run.id });
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
});
return "COMPLETED";
}
await this.#retryAttempt({
run: taskRunAttempt.taskRun,
retry: completion.retry,
checkpointEventId: checkpointCreateResult.event.id,
supportsLazyAttempts: taskRunAttempt.backgroundWorker.supportsLazyAttempts,
supportsRetryCheckpoints,
});
return "RETRIED";
} else {
// Now we need to "complete" the task run event/span
await eventRepository.completeEvent(taskRunAttempt.taskRun.spanId, {
endTime: new Date(),
attributes: {
isError: true,
},
events: [
{
name: "exception",
time: new Date(),
properties: {
exception: createExceptionPropertiesFromError(sanitizedError),
},
},
],
});
if (
sanitizedError.type === "INTERNAL_ERROR" &&
sanitizedError.code === "GRACEFUL_EXIT_TIMEOUT"
) {
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "SYSTEM_FAILURE",
completedAt: new Date(),
});
// We need to fail all incomplete spans
const inProgressEvents = await eventRepository.queryIncompleteEvents({
attemptId: execution.attempt.id,
});
logger.debug("Failing in-progress events", {
inProgressEvents: inProgressEvents.map((event) => event.id),
});
const exception = {
type: "Graceful exit timeout",
message: sanitizedError.message,
};
await Promise.all(
inProgressEvents.map((event) => {
return eventRepository.crashEvent({
event: event,
crashedAt: new Date(),
exception,
});
})
);
} else {
const finalizeService = new FinalizeTaskRunService();
await finalizeService.call({
id: taskRunAttempt.taskRunId,
status: "COMPLETED_WITH_ERRORS",
completedAt: new Date(),
});
}
if (!env || env.type !== "DEVELOPMENT") {
await ResumeTaskRunDependenciesService.enqueue(taskRunAttempt.id, this._prisma);
}
return "COMPLETED";
}
}
async #retryAttempt({
run,
retry,
checkpointEventId,
supportsLazyAttempts,
supportsRetryCheckpoints,
}: {
run: TaskRun;
retry: TaskRunExecutionRetry;
checkpointEventId?: string;
supportsLazyAttempts: boolean;
supportsRetryCheckpoints?: boolean;
}) {
const retryViaQueue = () => {
// We have to replace a potential RESUME with EXECUTE to correctly retry the attempt
return marqs?.replaceMessage(
run.id,
{
type: "EXECUTE",
taskIdentifier: run.taskIdentifier,
checkpointEventId: supportsRetryCheckpoints ? checkpointEventId : undefined,
retryCheckpointsDisabled: !supportsRetryCheckpoints,
},
retry.timestamp
);
};
const retryDirectly = () => {
return RetryAttemptService.enqueue(run.id, this._prisma, new Date(retry.timestamp));
};
// There's a checkpoint, so we need to go through the queue
if (checkpointEventId) {
if (!supportsRetryCheckpoints) {
logger.error("Worker does not support retry checkpoints, but a checkpoint was created", {
runId: run.id,
checkpointEventId,
});
}
await retryViaQueue();
return;
}
// Workers without lazy attempt support always need to go through the queue, which is where the attempt is created
if (!supportsLazyAttempts) {
await retryViaQueue();
return;
}
// Workers that never checkpoint between attempts will exit after completing their current attempt if the retry delay exceeds the threshold
if (!supportsRetryCheckpoints && retry.delay >= env.CHECKPOINT_THRESHOLD_IN_MS) {
await retryViaQueue();
return;
}
// The worker is still running and waiting for a retry message
await retryDirectly();
}
#generateMetadataAttributesForNextAttempt(execution: TaskRunExecution) {
const context = TaskRunContext.parse(execution);
// @ts-ignore
context.attempt = {
number: context.attempt.number + 1,
};
return flattenAttributes(context, "ctx");
}
async #getEnvironment(id: string) {
return await this._prisma.runtimeEnvironment.findUniqueOrThrow({
where: {
id,
},
include: {
project: true,
organization: true,
},
});
}
}
async function findAttempt(prismaClient: PrismaClientOrTransaction, friendlyId: string) {
return prismaClient.taskRunAttempt.findUnique({
where: { friendlyId },
include: {
taskRun: true,
backgroundWorkerTask: true,
backgroundWorker: {
select: {
id: true,
supportsLazyAttempts: true,
},
},
},
});
}