235 lines
7.6 KiB
TypeScript
235 lines
7.6 KiB
TypeScript
import { BackgroundWorkerMetadata, tryCatch } from "@trigger.dev/core/v3";
|
|
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
|
|
import type { PrismaClientOrTransaction, WorkerDeployment } from "@trigger.dev/database";
|
|
import { logger } from "~/services/logger.server";
|
|
import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
|
|
import {
|
|
type TaskMetadataCache,
|
|
type TaskMetadataEntry,
|
|
} from "~/services/taskMetadataCache.server";
|
|
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
|
import { BaseService, ServiceValidationError } from "./baseService.server";
|
|
import { syncDeclarativeSchedules } from "./createBackgroundWorker.server";
|
|
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
|
import { compareDeploymentVersions } from "../utils/deploymentVersions";
|
|
|
|
export type ChangeCurrentDeploymentDirection = "promote" | "rollback";
|
|
|
|
export class ChangeCurrentDeploymentService extends BaseService {
|
|
private readonly _taskMetaCache: TaskMetadataCache;
|
|
|
|
constructor(
|
|
prisma?: PrismaClientOrTransaction,
|
|
replica?: PrismaClientOrTransaction,
|
|
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
|
) {
|
|
super(prisma, replica);
|
|
this._taskMetaCache = taskMetaCache;
|
|
}
|
|
|
|
public async call(
|
|
deployment: WorkerDeployment,
|
|
direction: ChangeCurrentDeploymentDirection,
|
|
disableVersionCheck?: boolean
|
|
) {
|
|
if (!deployment.workerId) {
|
|
throw new ServiceValidationError(
|
|
direction === "promote"
|
|
? "Deployment is not associated with a worker and cannot be promoted."
|
|
: "Deployment is not associated with a worker and cannot be rolled back."
|
|
);
|
|
}
|
|
|
|
if (deployment.status !== "DEPLOYED") {
|
|
throw new ServiceValidationError(
|
|
direction === "promote"
|
|
? "Deployment must be in the DEPLOYED state to be promoted."
|
|
: "Deployment must be in the DEPLOYED state to be rolled back."
|
|
);
|
|
}
|
|
|
|
const currentPromotion = await this._prisma.workerDeploymentPromotion.findFirst({
|
|
where: {
|
|
environmentId: deployment.environmentId,
|
|
label: CURRENT_DEPLOYMENT_LABEL,
|
|
},
|
|
select: {
|
|
deployment: {
|
|
select: { id: true, version: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (currentPromotion) {
|
|
if (currentPromotion.deployment.id === deployment.id) {
|
|
throw new ServiceValidationError("Deployment is already the current deployment.");
|
|
}
|
|
|
|
// if there is a current promotion, we have to validate we are moving in the right direction based on the deployment versions
|
|
if (!disableVersionCheck) {
|
|
switch (direction) {
|
|
case "promote": {
|
|
if (
|
|
compareDeploymentVersions(currentPromotion.deployment.version, deployment.version) >=
|
|
0
|
|
) {
|
|
throw new ServiceValidationError(
|
|
"Cannot promote a deployment that is older than the current deployment."
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
case "rollback": {
|
|
if (
|
|
compareDeploymentVersions(currentPromotion.deployment.version, deployment.version) <=
|
|
0
|
|
) {
|
|
throw new ServiceValidationError(
|
|
"Cannot rollback to a deployment that is newer than the current deployment."
|
|
);
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
//set this deployment as the current deployment for this environment
|
|
await this._prisma.workerDeploymentPromotion.upsert({
|
|
where: {
|
|
environmentId_label: {
|
|
environmentId: deployment.environmentId,
|
|
label: CURRENT_DEPLOYMENT_LABEL,
|
|
},
|
|
},
|
|
create: {
|
|
deploymentId: deployment.id,
|
|
environmentId: deployment.environmentId,
|
|
label: CURRENT_DEPLOYMENT_LABEL,
|
|
},
|
|
update: {
|
|
deploymentId: deployment.id,
|
|
},
|
|
});
|
|
|
|
const [fetchTasksError, tasks] = await tryCatch(
|
|
this._prisma.backgroundWorkerTask.findMany({
|
|
where: { workerId: deployment.workerId! },
|
|
select: {
|
|
slug: true,
|
|
triggerSource: true,
|
|
ttl: true,
|
|
queue: { select: { id: true, name: true } },
|
|
},
|
|
})
|
|
);
|
|
|
|
if (fetchTasksError) {
|
|
logger.error("Error fetching worker tasks on deployment change", {
|
|
error: fetchTasksError,
|
|
});
|
|
}
|
|
|
|
if (tasks) {
|
|
// Side effect 1: refresh the `TaskIdentifier` table and the existing
|
|
// `tids:` Redis cache so the task-listing UI reflects the new deploy.
|
|
const [syncIdentifiersError] = await tryCatch(
|
|
syncTaskIdentifiers(
|
|
deployment.environmentId,
|
|
deployment.projectId,
|
|
deployment.workerId!,
|
|
tasks.map((t) => ({ id: t.slug, triggerSource: t.triggerSource }))
|
|
)
|
|
);
|
|
|
|
if (syncIdentifiersError) {
|
|
logger.error("Error syncing task identifiers on deployment change", {
|
|
error: syncIdentifiersError,
|
|
});
|
|
}
|
|
|
|
// Side effect 2: refresh the `task-meta:` cache that the queue resolver
|
|
// reads from. Independent of side effect 1 — if `syncTaskIdentifiers`
|
|
// throws, the queue resolver still gets a warm cache for the new worker.
|
|
const metadataEntries: TaskMetadataEntry[] = tasks.map((t) => ({
|
|
slug: t.slug,
|
|
ttl: t.ttl,
|
|
triggerSource: t.triggerSource,
|
|
queueId: t.queue?.id ?? null,
|
|
queueName: t.queue?.name ?? "",
|
|
}));
|
|
|
|
// Cache calls log+swallow internally.
|
|
await this._taskMetaCache.populateByCurrentWorker(
|
|
deployment.environmentId,
|
|
deployment.workerId!,
|
|
metadataEntries
|
|
);
|
|
}
|
|
|
|
const [scheduleSyncError] = await tryCatch(this.#syncSchedulesForDeployment(deployment));
|
|
|
|
if (scheduleSyncError) {
|
|
logger.error("Error syncing declarative schedules on deployment change", {
|
|
error: scheduleSyncError,
|
|
});
|
|
}
|
|
|
|
// Only V1 engine workers need the WAITING_FOR_DEPLOY drain — V2 runs sit
|
|
// in PENDING_VERSION and are handled out of band, so enqueuing here for V2
|
|
// just produces empty scans of the TaskRun status index.
|
|
const worker = await this._prisma.backgroundWorker.findFirst({
|
|
where: { id: deployment.workerId },
|
|
select: { engine: true },
|
|
});
|
|
|
|
if (worker?.engine === "V1") {
|
|
await ExecuteTasksWaitingForDeployService.enqueue(deployment.workerId);
|
|
}
|
|
}
|
|
|
|
async #syncSchedulesForDeployment(deployment: WorkerDeployment) {
|
|
const worker = await this._prisma.backgroundWorker.findFirst({
|
|
where: { id: deployment.workerId! },
|
|
});
|
|
|
|
if (!worker) {
|
|
logger.error("Worker not found for deployment schedule sync", {
|
|
deploymentId: deployment.id,
|
|
workerId: deployment.workerId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const parsed = BackgroundWorkerMetadata.safeParse(worker.metadata);
|
|
|
|
if (!parsed.success) {
|
|
logger.error("Failed to parse worker metadata for schedule sync", {
|
|
deploymentId: deployment.id,
|
|
workerId: deployment.workerId,
|
|
error: parsed.error,
|
|
});
|
|
return;
|
|
}
|
|
|
|
const environment = await this._prisma.runtimeEnvironment.findFirst({
|
|
where: { id: deployment.environmentId },
|
|
include: {
|
|
project: true,
|
|
organization: true,
|
|
orgMember: true,
|
|
},
|
|
});
|
|
|
|
if (!environment) {
|
|
logger.error("Environment not found for deployment schedule sync", {
|
|
deploymentId: deployment.id,
|
|
environmentId: deployment.environmentId,
|
|
});
|
|
return;
|
|
}
|
|
|
|
await syncDeclarativeSchedules(parsed.data.tasks, worker, environment, this._prisma);
|
|
}
|
|
}
|