chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,990 @@
|
||||
import type {
|
||||
BackgroundWorkerMetadata,
|
||||
BackgroundWorkerSourceFileMetadata,
|
||||
CreateBackgroundWorkerRequestBody,
|
||||
PromptResource,
|
||||
QueueManifest,
|
||||
TaskResource,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { BackgroundWorkerId, stringifyDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { BackgroundWorker, TaskQueue, TaskQueueType } from "@trigger.dev/database";
|
||||
import cronstrue from "cronstrue";
|
||||
import type { PrismaClientOrTransaction } from "~/db.server";
|
||||
import { $transaction, Prisma } from "~/db.server";
|
||||
import { sanitizeQueueName } from "~/models/taskQueue.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
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 { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import { engine } from "../runEngine.server";
|
||||
import {
|
||||
removeQueueConcurrencyLimits,
|
||||
updateEnvConcurrencyLimits,
|
||||
updateQueueConcurrencyLimits,
|
||||
} from "../runQueue.server";
|
||||
import { scheduleEngine } from "../scheduleEngine.server";
|
||||
import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion";
|
||||
import { clampMaxDuration } from "../utils/maxDuration";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { CheckScheduleService } from "./checkSchedule.server";
|
||||
import { projectPubSub } from "./projectPubSub.server";
|
||||
|
||||
import { assertNoDuplicateTaskIds } from "./duplicateTaskIds.server";
|
||||
import { stripBackgroundWorkerMetadataForStorage } from "./stripBackgroundWorkerMetadataForStorage.server";
|
||||
export { stripBackgroundWorkerMetadataForStorage };
|
||||
|
||||
export class CreateBackgroundWorkerService extends BaseService {
|
||||
private readonly _taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
super(prisma, replica);
|
||||
this._taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
public async call(
|
||||
projectRef: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: CreateBackgroundWorkerRequestBody
|
||||
): Promise<BackgroundWorker> {
|
||||
return this.traceWithEnv("call", environment, async (span) => {
|
||||
span.setAttribute("projectRef", projectRef);
|
||||
|
||||
const project = await this._prisma.project.findFirstOrThrow({
|
||||
where: {
|
||||
externalRef: projectRef,
|
||||
environments: {
|
||||
some: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
include: {
|
||||
backgroundWorkers: {
|
||||
where: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const latestBackgroundWorker = project.backgroundWorkers[0];
|
||||
|
||||
if (latestBackgroundWorker?.contentHash === body.metadata.contentHash) {
|
||||
return latestBackgroundWorker;
|
||||
}
|
||||
|
||||
const nextVersion = calculateNextBuildVersion(project.backgroundWorkers[0]?.version);
|
||||
|
||||
logger.debug(`Creating background worker`, {
|
||||
nextVersion,
|
||||
lastVersion: project.backgroundWorkers[0]?.version,
|
||||
});
|
||||
|
||||
const backgroundWorker = await this._prisma.backgroundWorker.create({
|
||||
data: {
|
||||
...BackgroundWorkerId.generate(),
|
||||
version: nextVersion,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
projectId: project.id,
|
||||
metadata: stripBackgroundWorkerMetadataForStorage(body.metadata),
|
||||
contentHash: body.metadata.contentHash,
|
||||
cliVersion: body.metadata.cliPackageVersion,
|
||||
sdkVersion: body.metadata.packageVersion,
|
||||
runtime: body.metadata.runtime,
|
||||
runtimeVersion: body.metadata.runtimeVersion,
|
||||
supportsLazyAttempts: body.supportsLazyAttempts,
|
||||
engine: body.engine,
|
||||
},
|
||||
});
|
||||
|
||||
//upgrade the project to engine "V2" if it's not already
|
||||
if (project.engine === "V1" && body.engine === "V2") {
|
||||
await this._prisma.project.update({
|
||||
where: {
|
||||
id: project.id,
|
||||
},
|
||||
data: {
|
||||
engine: "V2",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const [filesError, tasksToBackgroundFiles] = await tryCatch(
|
||||
createBackgroundFiles(
|
||||
body.metadata.sourceFiles,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
this._prisma
|
||||
)
|
||||
);
|
||||
|
||||
if (filesError) {
|
||||
logger.error("Error creating background worker files", {
|
||||
error: filesError,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError("Error creating background worker files");
|
||||
}
|
||||
|
||||
const [resourcesError, workerTaskEntries] = await tryCatch(
|
||||
createWorkerResources(
|
||||
body.metadata,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
this._prisma,
|
||||
tasksToBackgroundFiles
|
||||
)
|
||||
);
|
||||
|
||||
if (resourcesError) {
|
||||
if (resourcesError instanceof ServiceValidationError) {
|
||||
// Customer-facing config error (e.g. duplicate task ids). Surface the
|
||||
// real message to the client via the rethrow.
|
||||
logger.warn("Error creating worker resources", {
|
||||
error: resourcesError.message,
|
||||
});
|
||||
throw resourcesError;
|
||||
}
|
||||
|
||||
logger.error("Error creating worker resources", {
|
||||
error: resourcesError,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
throw new ServiceValidationError("Error creating worker resources");
|
||||
}
|
||||
|
||||
const [schedulesError] = await tryCatch(
|
||||
syncDeclarativeSchedules(body.metadata.tasks, backgroundWorker, environment, this._prisma)
|
||||
);
|
||||
|
||||
if (schedulesError) {
|
||||
if (schedulesError instanceof ServiceValidationError) {
|
||||
// Customer schedule config (typically invalid cron). Surface to
|
||||
// client via the rethrow; system returns gracefully.
|
||||
logger.warn("Error syncing declarative schedules", {
|
||||
error: schedulesError.message,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
throw schedulesError;
|
||||
}
|
||||
|
||||
// Wrapping the underlying error into a ServiceValidationError below
|
||||
// would otherwise hide it once the SDK-level filter drops SVEs; log at
|
||||
// error so the underlying cause stays visible. Mirrors the
|
||||
// waitpointCompletionPacket.server.ts pattern from dac9c83bd.
|
||||
logger.error("Error syncing declarative schedules", {
|
||||
error: schedulesError,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError("Error syncing declarative schedules");
|
||||
}
|
||||
|
||||
const [syncIdentifiersError] = await tryCatch(
|
||||
syncTaskIdentifiers(
|
||||
environment.id,
|
||||
project.id,
|
||||
backgroundWorker.id,
|
||||
body.metadata.tasks.map((t) => ({ id: t.id, triggerSource: t.triggerSource }))
|
||||
)
|
||||
);
|
||||
|
||||
if (syncIdentifiersError) {
|
||||
logger.error("Error syncing task identifiers", {
|
||||
error: syncIdentifiersError,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
|
||||
// Populate task metadata cache. DEV workers are always "current" because
|
||||
// `findCurrentWorkerFromEnvironment` resolves DEV current as the latest
|
||||
// worker by createdAt. Non-DEV (deploy-built) workers are not promoted
|
||||
// here — promotion writes the `:env:` keyspace later in
|
||||
// changeCurrentDeployment / createDeploymentBackgroundWorkerV3.
|
||||
// Cache calls log+swallow internally, so a Redis blip can't break
|
||||
// anything else here. Empty `workerTaskEntries` is intentional — the
|
||||
// populate methods clear stale hashes for zero-task deploys.
|
||||
if (workerTaskEntries) {
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
await this._taskMetaCache.populateByCurrentWorker(
|
||||
environment.id,
|
||||
backgroundWorker.id,
|
||||
workerTaskEntries
|
||||
);
|
||||
} else {
|
||||
await this._taskMetaCache.populateByWorker(backgroundWorker.id, workerTaskEntries);
|
||||
}
|
||||
}
|
||||
|
||||
const [updateConcurrencyLimitsError] = await tryCatch(
|
||||
updateEnvConcurrencyLimits(environment)
|
||||
);
|
||||
|
||||
if (updateConcurrencyLimitsError) {
|
||||
logger.error("Error updating environment concurrency limits", {
|
||||
error: updateConcurrencyLimitsError,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
|
||||
const [publishError] = await tryCatch(
|
||||
projectPubSub.publish(`project:${project.id}:env:${environment.id}`, "WORKER_CREATED", {
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
createdAt: backgroundWorker.createdAt,
|
||||
taskCount: body.metadata.tasks.length,
|
||||
type: "local",
|
||||
})
|
||||
);
|
||||
|
||||
if (publishError) {
|
||||
logger.error("Error publishing WORKER_CREATED event", {
|
||||
error: publishError,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
|
||||
if (backgroundWorker.engine === "V2") {
|
||||
const [schedulePendingVersionsError] = await tryCatch(
|
||||
engine.scheduleEnqueueRunsForBackgroundWorker(backgroundWorker.id)
|
||||
);
|
||||
|
||||
if (schedulePendingVersionsError) {
|
||||
logger.error("Error scheduling pending versions", {
|
||||
error: schedulePendingVersionsError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return backgroundWorker;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function createWorkerResources(
|
||||
metadata: BackgroundWorkerMetadata,
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
tasksToBackgroundFiles?: Map<string, string>
|
||||
): Promise<TaskMetadataEntry[]> {
|
||||
// Defense-in-depth against two tasks sharing an id (across all task types,
|
||||
// e.g. a schedule and a regular task). Note: the CLI's resource catalog keys
|
||||
// tasks by id and overwrites collisions, so duplicates are normally already
|
||||
// collapsed before reaching here — this guards against any client that sends
|
||||
// an un-deduplicated task list.
|
||||
assertNoDuplicateTaskIds(metadata.tasks);
|
||||
|
||||
// Create the queues
|
||||
const queues = await createWorkerQueues(metadata, worker, environment, prisma);
|
||||
|
||||
// Create the tasks
|
||||
const taskEntries = await createWorkerTasks(
|
||||
metadata,
|
||||
queues,
|
||||
worker,
|
||||
environment,
|
||||
prisma,
|
||||
tasksToBackgroundFiles
|
||||
);
|
||||
|
||||
// Register prompts
|
||||
if (metadata.prompts && metadata.prompts.length > 0) {
|
||||
await createWorkerPrompts(metadata.prompts, worker, environment, prisma);
|
||||
}
|
||||
|
||||
return taskEntries;
|
||||
}
|
||||
|
||||
async function createWorkerTasks(
|
||||
metadata: BackgroundWorkerMetadata,
|
||||
queues: Array<TaskQueue>,
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
tasksToBackgroundFiles?: Map<string, string>
|
||||
): Promise<TaskMetadataEntry[]> {
|
||||
// Create tasks in chunks of 20
|
||||
const CHUNK_SIZE = 20;
|
||||
const entries: TaskMetadataEntry[] = [];
|
||||
for (let i = 0; i < metadata.tasks.length; i += CHUNK_SIZE) {
|
||||
const chunk = metadata.tasks.slice(i, i + CHUNK_SIZE);
|
||||
const chunkEntries = await Promise.all(
|
||||
chunk.map((task) =>
|
||||
createWorkerTask(task, queues, worker, environment, prisma, tasksToBackgroundFiles)
|
||||
)
|
||||
);
|
||||
for (const entry of chunkEntries) {
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function createWorkerTask(
|
||||
task: TaskResource,
|
||||
queues: Array<TaskQueue>,
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
tasksToBackgroundFiles?: Map<string, string>
|
||||
): Promise<TaskMetadataEntry | null> {
|
||||
// Hoisted so the P2002 catch branch can return the same entry shape.
|
||||
let queue: TaskQueue | undefined;
|
||||
let resolvedTriggerSource: "SCHEDULED" | "AGENT" | "STANDARD" | undefined;
|
||||
let resolvedTtl: string | null | undefined;
|
||||
|
||||
try {
|
||||
queue = queues.find((queue) => queue.name === task.queue?.name);
|
||||
|
||||
if (!queue) {
|
||||
// Create a TaskQueue
|
||||
queue = await createWorkerQueue(
|
||||
{
|
||||
name: task.queue?.name ?? `task/${task.id}`,
|
||||
concurrencyLimit: task.queue?.concurrencyLimit,
|
||||
},
|
||||
task.id,
|
||||
task.queue?.name ? "NAMED" : "VIRTUAL",
|
||||
worker,
|
||||
environment,
|
||||
prisma
|
||||
);
|
||||
}
|
||||
|
||||
resolvedTriggerSource =
|
||||
task.triggerSource === "schedule"
|
||||
? ("SCHEDULED" as const)
|
||||
: task.triggerSource === "agent"
|
||||
? ("AGENT" as const)
|
||||
: ("STANDARD" as const);
|
||||
|
||||
resolvedTtl =
|
||||
typeof task.ttl === "number" ? (stringifyDuration(task.ttl) ?? null) : (task.ttl ?? null);
|
||||
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("task"),
|
||||
projectId: worker.projectId,
|
||||
runtimeEnvironmentId: worker.runtimeEnvironmentId,
|
||||
workerId: worker.id,
|
||||
slug: task.id,
|
||||
description: task.description,
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
retryConfig: task.retry,
|
||||
queueConfig: task.queue,
|
||||
machineConfig: task.machine,
|
||||
triggerSource: resolvedTriggerSource,
|
||||
config: task.agentConfig ? (task.agentConfig as any) : undefined,
|
||||
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
|
||||
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
|
||||
ttl: resolvedTtl,
|
||||
queueId: queue.id,
|
||||
payloadSchema: task.payloadSchema as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
slug: task.id,
|
||||
ttl: resolvedTtl,
|
||||
triggerSource: resolvedTriggerSource,
|
||||
queueId: queue.id,
|
||||
queueName: queue.name,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// The error code for unique constraint violation in Prisma is P2002
|
||||
if (error.code === "P2002") {
|
||||
// Retry landing after the first attempt's row was already written.
|
||||
const existing = await prisma.backgroundWorkerTask.findFirst({
|
||||
where: { workerId: worker.id, slug: task.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
logger.warn("Attempted to recreate background worker task", {
|
||||
task,
|
||||
worker,
|
||||
});
|
||||
|
||||
if (existing && queue && resolvedTriggerSource && resolvedTtl !== undefined) {
|
||||
return {
|
||||
slug: task.id,
|
||||
ttl: resolvedTtl,
|
||||
triggerSource: resolvedTriggerSource,
|
||||
queueId: queue.id,
|
||||
queueName: queue.name,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
logger.error("Prisma Error creating background worker task", {
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
},
|
||||
task,
|
||||
worker,
|
||||
});
|
||||
}
|
||||
} else if (error instanceof Error) {
|
||||
logger.error("Error creating background worker task", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
task,
|
||||
worker,
|
||||
});
|
||||
} else {
|
||||
logger.error("Unknown error creating background worker task", {
|
||||
error,
|
||||
task,
|
||||
worker,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkerQueues(
|
||||
metadata: BackgroundWorkerMetadata,
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction
|
||||
) {
|
||||
if (!metadata.queues) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const CHUNK_SIZE = 20;
|
||||
const allQueues: Awaited<ReturnType<typeof createWorkerQueue>>[] = [];
|
||||
|
||||
// Process queues in chunks
|
||||
for (let i = 0; i < metadata.queues.length; i += CHUNK_SIZE) {
|
||||
const chunk = metadata.queues.slice(i, i + CHUNK_SIZE);
|
||||
const queueChunk = await Promise.all(
|
||||
chunk.map(async (queue) => {
|
||||
return createWorkerQueue(queue, queue.name, "NAMED", worker, environment, prisma);
|
||||
})
|
||||
);
|
||||
allQueues.push(...queueChunk.filter(Boolean));
|
||||
}
|
||||
|
||||
return allQueues;
|
||||
}
|
||||
|
||||
async function createWorkerQueue(
|
||||
queue: QueueManifest,
|
||||
orderableName: string,
|
||||
queueType: TaskQueueType,
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction
|
||||
) {
|
||||
let queueName = sanitizeQueueName(queue.name);
|
||||
|
||||
const baseConcurrencyLimit =
|
||||
typeof queue.concurrencyLimit === "number"
|
||||
? Math.max(Math.min(queue.concurrencyLimit, environment.maximumConcurrencyLimit), 0)
|
||||
: queue.concurrencyLimit;
|
||||
|
||||
const taskQueue = await upsertWorkerQueueRecord(
|
||||
queueName,
|
||||
baseConcurrencyLimit ?? null,
|
||||
orderableName,
|
||||
queueType,
|
||||
worker,
|
||||
prisma
|
||||
);
|
||||
|
||||
const newConcurrencyLimit = taskQueue.concurrencyLimit;
|
||||
|
||||
if (!taskQueue.paused) {
|
||||
if (typeof newConcurrencyLimit === "number") {
|
||||
logger.debug("createWorkerQueue: updating concurrency limit", {
|
||||
workerId: worker.id,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
concurrencyLimit: newConcurrencyLimit,
|
||||
});
|
||||
await updateQueueConcurrencyLimits(environment, taskQueue.name, newConcurrencyLimit);
|
||||
} else {
|
||||
logger.debug("createWorkerQueue: removing concurrency limit", {
|
||||
workerId: worker.id,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
concurrencyLimit: newConcurrencyLimit,
|
||||
});
|
||||
await removeQueueConcurrencyLimits(environment, taskQueue.name);
|
||||
}
|
||||
} else {
|
||||
logger.debug("createWorkerQueue: queue is paused, not updating concurrency limit", {
|
||||
workerId: worker.id,
|
||||
taskQueue,
|
||||
orgId: environment.organizationId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
}
|
||||
|
||||
return taskQueue;
|
||||
}
|
||||
|
||||
async function upsertWorkerQueueRecord(
|
||||
queueName: string,
|
||||
concurrencyLimit: number | null,
|
||||
orderableName: string,
|
||||
queueType: TaskQueueType,
|
||||
worker: BackgroundWorker,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
attempt: number = 0
|
||||
): Promise<TaskQueue> {
|
||||
if (attempt > 3) {
|
||||
throw new Error("Failed to insert queue record");
|
||||
}
|
||||
|
||||
try {
|
||||
let taskQueue = await prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
runtimeEnvironmentId: worker.runtimeEnvironmentId,
|
||||
name: queueName,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskQueue) {
|
||||
taskQueue = await prisma.taskQueue.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("queue"),
|
||||
version: "V2",
|
||||
name: queueName,
|
||||
orderableName,
|
||||
concurrencyLimit,
|
||||
runtimeEnvironmentId: worker.runtimeEnvironmentId,
|
||||
projectId: worker.projectId,
|
||||
type: queueType,
|
||||
workers: {
|
||||
connect: {
|
||||
id: worker.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const hasOverride = taskQueue.concurrencyLimitOverriddenAt !== null;
|
||||
|
||||
taskQueue = await prisma.taskQueue.update({
|
||||
where: {
|
||||
id: taskQueue.id,
|
||||
},
|
||||
data: {
|
||||
workers: { connect: { id: worker.id } },
|
||||
version: "V2",
|
||||
orderableName,
|
||||
// If overridden, keep current limit and update base; otherwise update limit normally
|
||||
concurrencyLimit: hasOverride ? undefined : concurrencyLimit,
|
||||
concurrencyLimitBase: hasOverride ? concurrencyLimit : undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return taskQueue;
|
||||
} catch (error) {
|
||||
// If the queue already exists, let's try again
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
return await upsertWorkerQueueRecord(
|
||||
queueName,
|
||||
concurrencyLimit,
|
||||
orderableName,
|
||||
queueType,
|
||||
worker,
|
||||
prisma,
|
||||
attempt + 1
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
//CreateDeclarativeScheduleError with a message
|
||||
export class CreateDeclarativeScheduleError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CreateDeclarativeScheduleError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncDeclarativeSchedules(
|
||||
tasks: TaskResource[],
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction
|
||||
) {
|
||||
const tasksWithDeclarativeSchedules = tasks.filter((task) => task.schedule);
|
||||
logger.info("Syncing declarative schedules", {
|
||||
tasksWithDeclarativeSchedules,
|
||||
environment,
|
||||
});
|
||||
|
||||
const existingDeclarativeSchedules = await prisma.taskSchedule.findMany({
|
||||
where: {
|
||||
type: "DECLARATIVE",
|
||||
projectId: environment.projectId,
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
const checkSchedule = new CheckScheduleService(prisma);
|
||||
|
||||
//start out by assuming they're all missing
|
||||
const missingSchedules = new Set<string>(
|
||||
existingDeclarativeSchedules.map((schedule) => schedule.id)
|
||||
);
|
||||
|
||||
//create/update schedules (+ instances)
|
||||
for (const task of tasksWithDeclarativeSchedules) {
|
||||
if (task.schedule === undefined) continue;
|
||||
|
||||
// Check if this schedule should be created in the current environment
|
||||
if (task.schedule.environments && task.schedule.environments.length > 0) {
|
||||
if (!task.schedule.environments.includes(environment.type)) {
|
||||
logger.debug("Skipping schedule creation due to environment filter", {
|
||||
taskId: task.id,
|
||||
environmentType: environment.type,
|
||||
allowedEnvironments: task.schedule.environments,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const existingSchedule = existingDeclarativeSchedules.find(
|
||||
(schedule) =>
|
||||
schedule.taskIdentifier === task.id &&
|
||||
schedule.instances.some((instance) => instance.environmentId === environment.id)
|
||||
);
|
||||
|
||||
//this throws errors if the schedule is invalid
|
||||
await checkSchedule.call(
|
||||
environment.projectId,
|
||||
{
|
||||
cron: task.schedule.cron,
|
||||
timezone: task.schedule.timezone,
|
||||
taskIdentifier: task.id,
|
||||
friendlyId: existingSchedule?.friendlyId,
|
||||
},
|
||||
[environment.id]
|
||||
);
|
||||
|
||||
if (existingSchedule) {
|
||||
const schedule = await prisma.taskSchedule.update({
|
||||
where: {
|
||||
id: existingSchedule.id,
|
||||
},
|
||||
data: {
|
||||
generatorExpression: task.schedule.cron,
|
||||
generatorDescription: cronstrue.toString(task.schedule.cron),
|
||||
timezone: task.schedule.timezone,
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
missingSchedules.delete(existingSchedule.id);
|
||||
const instance = schedule.instances.at(0);
|
||||
if (instance) {
|
||||
await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id });
|
||||
} else {
|
||||
throw new CreateDeclarativeScheduleError(
|
||||
`Missing instance for declarative schedule ${schedule.id}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const newSchedule = await prisma.taskSchedule.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("sched"),
|
||||
projectId: environment.projectId,
|
||||
taskIdentifier: task.id,
|
||||
generatorExpression: task.schedule.cron,
|
||||
generatorDescription: cronstrue.toString(task.schedule.cron),
|
||||
timezone: task.schedule.timezone,
|
||||
type: "DECLARATIVE",
|
||||
instances: {
|
||||
create: [
|
||||
{
|
||||
environmentId: environment.id,
|
||||
projectId: environment.projectId,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
const instance = newSchedule.instances.at(0);
|
||||
|
||||
if (instance) {
|
||||
await scheduleEngine.registerNextTaskScheduleInstance({ instanceId: instance.id });
|
||||
} else {
|
||||
throw new CreateDeclarativeScheduleError(
|
||||
`Missing instance for declarative schedule ${newSchedule.id}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Delete instances for this environment
|
||||
//Delete schedules that have no instances left
|
||||
const potentiallyDeletableSchedules = await prisma.taskSchedule.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: Array.from(missingSchedules),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
instances: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const schedule of potentiallyDeletableSchedules) {
|
||||
const canDeleteSchedule =
|
||||
schedule.instances.length === 0 ||
|
||||
schedule.instances.every((instance) => instance.environmentId === environment.id);
|
||||
|
||||
if (canDeleteSchedule) {
|
||||
//we can delete schedules with no instances other than ones for the current environment
|
||||
await prisma.taskSchedule.delete({
|
||||
where: {
|
||||
id: schedule.id,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
//otherwise we delete the instance (other environments remain untouched)
|
||||
await prisma.taskScheduleInstance.deleteMany({
|
||||
where: {
|
||||
taskScheduleId: schedule.id,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createBackgroundFiles(
|
||||
files: Array<BackgroundWorkerSourceFileMetadata> | undefined,
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction
|
||||
) {
|
||||
// Maps from each taskId to the backgroundWorkerFileId
|
||||
const results = new Map<string, string>();
|
||||
|
||||
if (!files) {
|
||||
return results;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const backgroundWorkerFile = await prisma.backgroundWorkerFile.upsert({
|
||||
where: {
|
||||
projectId_contentHash: {
|
||||
projectId: environment.projectId,
|
||||
contentHash: file.contentHash,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
friendlyId: generateFriendlyId("file"),
|
||||
projectId: environment.projectId,
|
||||
contentHash: file.contentHash,
|
||||
filePath: file.filePath,
|
||||
contents: Buffer.from(file.contents),
|
||||
backgroundWorkers: {
|
||||
connect: {
|
||||
id: worker.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
update: {
|
||||
backgroundWorkers: {
|
||||
connect: {
|
||||
id: worker.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
for (const taskId of file.taskIds) {
|
||||
results.set(taskId, backgroundWorkerFile.id);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
import { createHash } from "crypto";
|
||||
|
||||
function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
async function createWorkerPrompts(
|
||||
prompts: PromptResource[],
|
||||
worker: BackgroundWorker,
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction
|
||||
) {
|
||||
for (const promptResource of prompts) {
|
||||
try {
|
||||
// Upsert the Prompt record (identity + schema)
|
||||
const prompt = await prisma.prompt.upsert({
|
||||
where: {
|
||||
projectId_runtimeEnvironmentId_slug: {
|
||||
projectId: worker.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
slug: promptResource.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
friendlyId: generateFriendlyId("prompt"),
|
||||
organizationId: environment.organizationId,
|
||||
projectId: worker.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
slug: promptResource.id,
|
||||
description: promptResource.description,
|
||||
filePath: promptResource.filePath,
|
||||
exportName: promptResource.exportName,
|
||||
variableSchema: promptResource.variableSchema as any,
|
||||
defaultModel: promptResource.model,
|
||||
defaultConfig: promptResource.config as any,
|
||||
},
|
||||
update: {
|
||||
description: promptResource.description,
|
||||
filePath: promptResource.filePath,
|
||||
exportName: promptResource.exportName,
|
||||
variableSchema: promptResource.variableSchema as any,
|
||||
defaultModel: promptResource.model,
|
||||
defaultConfig: promptResource.config as any,
|
||||
},
|
||||
});
|
||||
|
||||
// Compute the version-definition hash for dedup. Includes the model and
|
||||
// config, not just the prompt text, so changing a code prompt's model or
|
||||
// config creates a new version — otherwise a model-only change is silently
|
||||
// skipped and the old model keeps serving.
|
||||
const contentString = promptResource.content ?? "";
|
||||
const contentHash = hashContent(
|
||||
JSON.stringify({
|
||||
content: contentString,
|
||||
model: promptResource.model ?? null,
|
||||
config: promptResource.config ?? null,
|
||||
})
|
||||
);
|
||||
|
||||
// Find the latest version overall (for version numbering) and the latest
|
||||
// code-sourced version (for content dedup). We compare against the latest
|
||||
// code version specifically so that dashboard edits don't interfere with
|
||||
// dedup — if the code hasn't changed since the last deploy, we skip even
|
||||
// if a dashboard edit happened in between.
|
||||
const latestVersion = await prisma.promptVersion.findFirst({
|
||||
where: { promptId: prompt.id },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
|
||||
const latestCodeVersion = await prisma.promptVersion.findFirst({
|
||||
where: { promptId: prompt.id, source: "code" },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
|
||||
if (latestCodeVersion?.contentHash === contentHash) {
|
||||
// Code definition (text + model + config) unchanged since last deploy —
|
||||
// skip creating a new version.
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextVersion = (latestVersion?.version ?? 0) + 1;
|
||||
|
||||
// Determine labels for the new version.
|
||||
// Deploys always move "current" to the new code version. If a dashboard
|
||||
// override exists, it sits on top via the "override" label and the API
|
||||
// serves that instead — so "current" movement is safe.
|
||||
const labels = ["latest", "current"];
|
||||
|
||||
// Wrap label removal + version creation in a transaction so labels
|
||||
// aren't stripped if the create fails (e.g. concurrent deploy race).
|
||||
await $transaction(prisma, async (tx) => {
|
||||
// Remove "latest" label from all existing versions
|
||||
if (latestVersion) {
|
||||
await tx.$executeRaw`
|
||||
UPDATE "prompt_versions"
|
||||
SET "labels" = array_remove("labels", 'latest')
|
||||
WHERE "promptId" = ${prompt.id} AND 'latest' = ANY("labels")
|
||||
`;
|
||||
}
|
||||
|
||||
// Remove "current" from any existing version
|
||||
await tx.$executeRaw`
|
||||
UPDATE "prompt_versions"
|
||||
SET "labels" = array_remove("labels", 'current')
|
||||
WHERE "promptId" = ${prompt.id} AND 'current' = ANY("labels")
|
||||
`;
|
||||
|
||||
await tx.promptVersion.create({
|
||||
data: {
|
||||
promptId: prompt.id,
|
||||
version: nextVersion,
|
||||
textContent: contentString,
|
||||
model: promptResource.model,
|
||||
config: promptResource.config as any,
|
||||
source: "code",
|
||||
contentHash,
|
||||
labels,
|
||||
workerId: worker.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
logger.debug("Registered prompt version", {
|
||||
promptSlug: promptResource.id,
|
||||
version: nextVersion,
|
||||
labels,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
|
||||
logger.warn("Prompt version already exists", { prompt: promptResource.id });
|
||||
} else {
|
||||
logger.error("Error creating prompt version", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
prompt: promptResource.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user