208 lines
7.9 KiB
TypeScript
208 lines
7.9 KiB
TypeScript
import type { InitializeBatchOptions } from "@internal/run-engine";
|
|
import { type CreateBatchRequestBody, type CreateBatchResponse } from "@trigger.dev/core/v3";
|
|
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
|
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
|
|
import { Evt } from "evt";
|
|
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
|
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
|
import { mintBatchFriendlyId } from "~/v3/runOpsMigration/mintBatchFriendlyId.server";
|
|
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
|
import { BatchRateLimitExceededError, getBatchLimits } from "../concerns/batchLimits.server";
|
|
import { DefaultQueueManager } from "../concerns/queues.server";
|
|
import { DefaultTriggerTaskValidator } from "../validators/triggerTaskValidator";
|
|
|
|
export type CreateBatchServiceOptions = {
|
|
triggerVersion?: string;
|
|
traceContext?: Record<string, string | undefined | Record<string, string | undefined>>;
|
|
spanParentAsLink?: boolean;
|
|
oneTimeUseToken?: string;
|
|
realtimeStreamsVersion?: "v1" | "v2";
|
|
triggerSource?: string;
|
|
};
|
|
|
|
/**
|
|
* Create Batch Service (Phase 1 of 2-phase batch API).
|
|
*
|
|
* This service handles Phase 1 of the streaming batch API:
|
|
* 1. Validates entitlement and queue limits
|
|
* 2. Creates BatchTaskRun in Postgres with status=PENDING, expectedCount set
|
|
* 3. For batchTriggerAndWait: blocks the parent run immediately
|
|
* 4. Initializes batch metadata in Redis
|
|
* 5. Returns batch ID - items are streamed separately via Phase 2
|
|
*
|
|
* The batch is NOT sealed until Phase 2 completes.
|
|
*/
|
|
export class CreateBatchService extends WithRunEngine {
|
|
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
|
|
private readonly queueConcern: DefaultQueueManager;
|
|
private readonly validator: DefaultTriggerTaskValidator;
|
|
|
|
constructor(protected readonly _prisma: PrismaClientOrTransaction = prisma) {
|
|
super({ prisma: _prisma });
|
|
|
|
this.queueConcern = new DefaultQueueManager(this._prisma, this._engine, this._replica);
|
|
this.validator = new DefaultTriggerTaskValidator();
|
|
}
|
|
|
|
/**
|
|
* Create a batch for 2-phase processing.
|
|
* Items will be streamed separately via the StreamBatchItemsService.
|
|
*/
|
|
public async call(
|
|
environment: AuthenticatedEnvironment,
|
|
body: CreateBatchRequestBody,
|
|
options: CreateBatchServiceOptions = {}
|
|
): Promise<CreateBatchResponse> {
|
|
try {
|
|
return await this.traceWithEnv<CreateBatchResponse>(
|
|
"createBatch()",
|
|
environment,
|
|
async (span) => {
|
|
const { id, friendlyId } = await mintBatchFriendlyId({
|
|
environment: {
|
|
organizationId: environment.organizationId,
|
|
id: environment.id,
|
|
orgFeatureFlags: environment.organization.featureFlags,
|
|
},
|
|
parentRunFriendlyId: body.parentRunId,
|
|
});
|
|
|
|
span.setAttribute("batchId", friendlyId);
|
|
span.setAttribute("runCount", body.runCount);
|
|
|
|
const entitlementValidation = await this.validator.validateEntitlement({
|
|
environment,
|
|
});
|
|
|
|
if (!entitlementValidation.ok) {
|
|
throw entitlementValidation.error;
|
|
}
|
|
|
|
const planType = entitlementValidation.plan?.type;
|
|
|
|
const { config, rateLimiter } = await getBatchLimits(environment.organization);
|
|
|
|
// Rate-limit before creating the batch, to stop bursts exceeding the limit.
|
|
const rateResult = await rateLimiter.limit(environment.id, body.runCount);
|
|
|
|
if (!rateResult.success) {
|
|
throw new BatchRateLimitExceededError(
|
|
rateResult.limit,
|
|
rateResult.remaining,
|
|
new Date(rateResult.reset),
|
|
body.runCount
|
|
);
|
|
}
|
|
|
|
// Note: Queue size limits are validated per-queue when batch items are processed,
|
|
// since we don't know which queues items will go to until they're streamed.
|
|
|
|
// BatchTaskRun.runtimeEnvironmentId no longer has an FK into RuntimeEnvironment;
|
|
// validate env existence app-side (passthrough when split is off).
|
|
await controlPlaneResolver.assertEnvExists(environment.id);
|
|
|
|
// Created PENDING; sealed (status -> PROCESSING) once items are streamed.
|
|
const batch = await this._engine.runStore.createBatchTaskRun({
|
|
id,
|
|
friendlyId,
|
|
runtimeEnvironmentId: environment.id,
|
|
status: "PENDING",
|
|
runCount: body.runCount,
|
|
expectedCount: body.runCount,
|
|
runIds: [],
|
|
batchVersion: "runengine:v2", // 2-phase streaming batch API
|
|
oneTimeUseToken: options.oneTimeUseToken,
|
|
idempotencyKey: body.idempotencyKey,
|
|
sealed: false,
|
|
});
|
|
|
|
this.onBatchTaskRunCreated.post(batch);
|
|
|
|
// Block parent run if this is a batchTriggerAndWait
|
|
if (body.parentRunId && body.resumeParentOnCompletion) {
|
|
await this._engine.blockRunWithCreatedBatch({
|
|
runId: RunId.fromFriendlyId(body.parentRunId),
|
|
batchId: batch.id,
|
|
environmentId: environment.id,
|
|
projectId: environment.projectId,
|
|
organizationId: environment.organizationId,
|
|
});
|
|
}
|
|
|
|
// Initialize batch metadata in Redis (without items)
|
|
const initOptions: InitializeBatchOptions = {
|
|
batchId: id,
|
|
friendlyId,
|
|
environmentId: environment.id,
|
|
environmentType: environment.type,
|
|
organizationId: environment.organizationId,
|
|
projectId: environment.projectId,
|
|
runCount: body.runCount,
|
|
parentRunId: body.parentRunId,
|
|
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
|
triggerVersion: options.triggerVersion,
|
|
traceContext: options.traceContext as Record<string, unknown> | undefined,
|
|
spanParentAsLink: options.spanParentAsLink,
|
|
realtimeStreamsVersion: options.realtimeStreamsVersion,
|
|
idempotencyKey: body.idempotencyKey,
|
|
processingConcurrency: config.processingConcurrency,
|
|
planType,
|
|
triggerSource: options.triggerSource,
|
|
};
|
|
|
|
await this._engine.initializeBatch(initOptions);
|
|
|
|
logger.info("Batch created", {
|
|
batchId: friendlyId,
|
|
runCount: body.runCount,
|
|
envId: environment.id,
|
|
projectId: environment.projectId,
|
|
parentRunId: body.parentRunId,
|
|
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
|
processingConcurrency: config.processingConcurrency,
|
|
});
|
|
|
|
return {
|
|
id: friendlyId,
|
|
runCount: body.runCount,
|
|
isCached: false,
|
|
idempotencyKey: body.idempotencyKey,
|
|
};
|
|
}
|
|
);
|
|
} catch (error) {
|
|
// Handle Prisma unique constraint violations
|
|
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
|
logger.debug("CreateBatchService: Prisma 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 create batch with a one-time use token as it has already been used."
|
|
);
|
|
} else {
|
|
throw new ServiceValidationError(
|
|
"Cannot create batch as it has already been created with the same idempotency key."
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
}
|