305 lines
7.6 KiB
TypeScript
305 lines
7.6 KiB
TypeScript
import type {
|
|
TaskRunExecution,
|
|
TaskRunExecutionRetry,
|
|
TaskRunFailedExecutionResult,
|
|
V3TaskRunExecution,
|
|
} from "@trigger.dev/core/v3";
|
|
import { calculateNextRetryDelay, RetryOptions } from "@trigger.dev/core/v3";
|
|
import type { Prisma, TaskRun } from "@trigger.dev/database";
|
|
import * as semver from "semver";
|
|
import { logger } from "~/services/logger.server";
|
|
import { sharedQueueTasks } from "./marqs/sharedQueueConsumer.server";
|
|
import { BaseService } from "./services/baseService.server";
|
|
import { CompleteAttemptService } from "./services/completeAttempt.server";
|
|
import { CreateTaskRunAttemptService } from "./services/createTaskRunAttempt.server";
|
|
import { isFailableRunStatus, isFinalAttemptStatus } from "./taskStatus";
|
|
|
|
const FailedTaskRunRetryGetPayload = {
|
|
select: {
|
|
id: true,
|
|
attempts: {
|
|
orderBy: {
|
|
createdAt: "desc",
|
|
},
|
|
take: 1,
|
|
},
|
|
lockedById: true, // task
|
|
lockedToVersionId: true, // worker
|
|
},
|
|
} as const;
|
|
|
|
type TaskRunWithAttempts = Prisma.TaskRunGetPayload<typeof FailedTaskRunRetryGetPayload>;
|
|
|
|
export class FailedTaskRunService extends BaseService {
|
|
public async call(anyRunId: string, completion: TaskRunFailedExecutionResult) {
|
|
logger.debug("[FailedTaskRunService] Handling failed task run", { anyRunId, completion });
|
|
|
|
const isFriendlyId = anyRunId.startsWith("run_");
|
|
|
|
const taskRun = await this.runStore.findRun(
|
|
{
|
|
friendlyId: isFriendlyId ? anyRunId : undefined,
|
|
id: !isFriendlyId ? anyRunId : undefined,
|
|
},
|
|
this._prisma
|
|
);
|
|
|
|
if (!taskRun) {
|
|
logger.error("[FailedTaskRunService] Task run not found", {
|
|
anyRunId,
|
|
completion,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (!isFailableRunStatus(taskRun.status)) {
|
|
logger.error("[FailedTaskRunService] Task run is not in a failable state", {
|
|
taskRun,
|
|
completion,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const retryHelper = new FailedTaskRunRetryHelper(this._prisma);
|
|
const retryResult = await retryHelper.call({
|
|
runId: taskRun.id,
|
|
completion,
|
|
});
|
|
|
|
logger.debug("[FailedTaskRunService] Completion result", {
|
|
runId: taskRun.id,
|
|
result: retryResult,
|
|
});
|
|
}
|
|
}
|
|
|
|
interface TaskRunWithWorker extends TaskRun {
|
|
lockedBy: { retryConfig: Prisma.JsonValue } | null;
|
|
lockedToVersion: { sdkVersion: string } | null;
|
|
}
|
|
|
|
export class FailedTaskRunRetryHelper extends BaseService {
|
|
async call({
|
|
runId,
|
|
completion,
|
|
isCrash,
|
|
}: {
|
|
runId: string;
|
|
completion: TaskRunFailedExecutionResult;
|
|
isCrash?: boolean;
|
|
}) {
|
|
const taskRun = await this.runStore.findRun(
|
|
{
|
|
id: runId,
|
|
},
|
|
FailedTaskRunRetryGetPayload,
|
|
this._prisma
|
|
);
|
|
|
|
if (!taskRun) {
|
|
logger.error("[FailedTaskRunRetryHelper] Task run not found", {
|
|
runId,
|
|
completion,
|
|
});
|
|
|
|
return "NO_TASK_RUN";
|
|
}
|
|
|
|
const retriableExecution = await this.#getRetriableAttemptExecution(taskRun, completion);
|
|
|
|
if (!retriableExecution) {
|
|
return "NO_EXECUTION";
|
|
}
|
|
|
|
logger.debug("[FailedTaskRunRetryHelper] Completing attempt", { taskRun, completion });
|
|
|
|
const completeAttempt = new CompleteAttemptService({
|
|
prisma: this._prisma,
|
|
isSystemFailure: !isCrash,
|
|
isCrash,
|
|
});
|
|
const completeResult = await completeAttempt.call({
|
|
completion,
|
|
execution: retriableExecution,
|
|
});
|
|
|
|
return completeResult;
|
|
}
|
|
|
|
async #getRetriableAttemptExecution(
|
|
run: TaskRunWithAttempts,
|
|
completion: TaskRunFailedExecutionResult
|
|
): Promise<V3TaskRunExecution | undefined> {
|
|
let attempt = run.attempts[0];
|
|
|
|
// We need to create an attempt if:
|
|
// - None exists yet
|
|
// - The last attempt has a final status, e.g. we failed between attempts
|
|
if (!attempt || isFinalAttemptStatus(attempt.status)) {
|
|
logger.debug("[FailedTaskRunRetryHelper] No attempts found", {
|
|
run,
|
|
completion,
|
|
});
|
|
|
|
const createAttempt = new CreateTaskRunAttemptService(this._prisma);
|
|
|
|
try {
|
|
const { execution } = await createAttempt.call({
|
|
runId: run.id,
|
|
// This ensures we correctly respect `maxAttempts = 1` when failing before the first attempt was created
|
|
startAtZero: true,
|
|
});
|
|
return execution;
|
|
} catch (error) {
|
|
logger.error("[FailedTaskRunRetryHelper] Failed to create attempt", {
|
|
run,
|
|
completion,
|
|
error,
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
// We already have an attempt with non-final status, let's use it
|
|
try {
|
|
const executionPayload = await sharedQueueTasks.getExecutionPayloadFromAttempt({
|
|
id: attempt.id,
|
|
skipStatusChecks: true,
|
|
});
|
|
|
|
return executionPayload?.execution;
|
|
} catch (error) {
|
|
logger.error("[FailedTaskRunRetryHelper] Failed to get execution payload", {
|
|
run,
|
|
completion,
|
|
error,
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
static getExecutionRetry({
|
|
run,
|
|
execution,
|
|
}: {
|
|
run: TaskRunWithWorker;
|
|
execution: TaskRunExecution;
|
|
}): TaskRunExecutionRetry | undefined {
|
|
try {
|
|
const retryConfig = FailedTaskRunRetryHelper.getRetryConfig({ run, execution });
|
|
if (!retryConfig) {
|
|
return;
|
|
}
|
|
|
|
const delay = calculateNextRetryDelay(retryConfig, execution.attempt.number);
|
|
|
|
if (!delay) {
|
|
logger.debug("[FailedTaskRunRetryHelper] No more retries", {
|
|
run,
|
|
execution,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
return {
|
|
timestamp: Date.now() + delay,
|
|
delay,
|
|
};
|
|
} catch (error) {
|
|
logger.error("[FailedTaskRunRetryHelper] Failed to get execution retry", {
|
|
run,
|
|
execution,
|
|
error,
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
static getRetryConfig({
|
|
run,
|
|
execution,
|
|
}: {
|
|
run: TaskRunWithWorker;
|
|
execution: TaskRunExecution;
|
|
}): RetryOptions | undefined {
|
|
try {
|
|
const retryConfig = run.lockedBy?.retryConfig;
|
|
|
|
if (!retryConfig) {
|
|
if (!run.lockedToVersion) {
|
|
logger.error("[FailedTaskRunRetryHelper] Run not locked to version", {
|
|
run,
|
|
execution,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
const sdkVersion = run.lockedToVersion.sdkVersion ?? "0.0.0";
|
|
const isValid = semver.valid(sdkVersion);
|
|
|
|
if (!isValid) {
|
|
logger.error("[FailedTaskRunRetryHelper] Invalid SDK version", {
|
|
run,
|
|
execution,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
// With older SDK versions, tasks only have a retry config stored in the DB if it's explicitly defined on the task itself
|
|
// It won't get populated with retry.default in trigger.config.ts
|
|
if (semver.lt(sdkVersion, FailedTaskRunRetryHelper.DEFAULT_RETRY_CONFIG_SINCE_VERSION)) {
|
|
logger.warn(
|
|
"[FailedTaskRunRetryHelper] SDK version not recent enough to determine retry config",
|
|
{
|
|
run,
|
|
execution,
|
|
}
|
|
);
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
const parsedRetryConfig = RetryOptions.nullable().safeParse(retryConfig);
|
|
|
|
if (!parsedRetryConfig.success) {
|
|
logger.error("[FailedTaskRunRetryHelper] Invalid retry config", {
|
|
run,
|
|
execution,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
if (!parsedRetryConfig.data) {
|
|
logger.debug("[FailedTaskRunRetryHelper] No retry config", {
|
|
run,
|
|
execution,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
return parsedRetryConfig.data;
|
|
} catch (error) {
|
|
logger.error("[FailedTaskRunRetryHelper] Failed to get execution retry", {
|
|
run,
|
|
execution,
|
|
error,
|
|
});
|
|
|
|
return;
|
|
}
|
|
}
|
|
|
|
static DEFAULT_RETRY_CONFIG_SINCE_VERSION = "3.1.0";
|
|
}
|