chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,759 @@
|
||||
import {
|
||||
type BatchTriggerTaskV2RequestBody,
|
||||
type BatchTriggerTaskV3RequestBody,
|
||||
type BatchTriggerTaskV3Response,
|
||||
type IOPacket,
|
||||
packetRequiresOffloading,
|
||||
parsePacket,
|
||||
TaskRunErrorCodes,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { type BatchTaskRun, Prisma } from "@trigger.dev/database";
|
||||
import { Evt } from "evt";
|
||||
import { z } from "zod";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findEnvironmentById } from "~/models/runtimeEnvironment.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { batchTriggerWorker } from "~/v3/batchTriggerWorker.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { mintAnchoredRunFriendlyId } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server";
|
||||
import { mintBatchFriendlyId } from "~/v3/runOpsMigration/mintBatchFriendlyId.server";
|
||||
import {
|
||||
downloadPacketFromObjectStore,
|
||||
uploadPacketToObjectStore,
|
||||
} from "../../v3/objectStore.server";
|
||||
import type { RunEngine } from "../../v3/runEngine.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { TriggerTaskService } from "../../v3/services/triggerTask.server";
|
||||
import { startActiveSpan } from "../../v3/tracer.server";
|
||||
import { TriggerFailedTaskService } from "./triggerFailedTask.server";
|
||||
|
||||
const PROCESSING_BATCH_SIZE = 50;
|
||||
const ASYNC_BATCH_PROCESS_SIZE_THRESHOLD = 20;
|
||||
const MAX_ATTEMPTS = 10;
|
||||
|
||||
export const BatchProcessingStrategy = z.enum(["sequential", "parallel"]);
|
||||
export type BatchProcessingStrategy = z.infer<typeof BatchProcessingStrategy>;
|
||||
|
||||
export const BatchProcessingOptions = z.object({
|
||||
batchId: z.string(),
|
||||
processingId: z.string(),
|
||||
range: z.object({ start: z.number().int(), count: z.number().int() }),
|
||||
attemptCount: z.number().int(),
|
||||
strategy: BatchProcessingStrategy,
|
||||
parentRunId: z.string().optional(),
|
||||
resumeParentOnCompletion: z.boolean().optional(),
|
||||
planType: z.string().optional(),
|
||||
});
|
||||
|
||||
export type BatchProcessingOptions = z.infer<typeof BatchProcessingOptions>;
|
||||
|
||||
export type BatchTriggerTaskServiceOptions = {
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, string | undefined | Record<string, string | undefined>>;
|
||||
spanParentAsLink?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
realtimeStreamsVersion?: "v1" | "v2";
|
||||
triggerSource?: string;
|
||||
triggerAction?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Larger batches, used in Run Engine v2
|
||||
*/
|
||||
export class RunEngineBatchTriggerService extends WithRunEngine {
|
||||
private _batchProcessingStrategy: BatchProcessingStrategy;
|
||||
public onBatchTaskRunCreated: Evt<BatchTaskRun> = new Evt();
|
||||
|
||||
constructor(
|
||||
batchProcessingStrategy?: BatchProcessingStrategy,
|
||||
protected readonly _prisma: PrismaClientOrTransaction = prisma,
|
||||
engine?: RunEngine
|
||||
) {
|
||||
super({ prisma: _prisma, engine });
|
||||
|
||||
// Eric note: We need to force sequential processing because when doing parallel, we end up with high-contention on the parent run lock
|
||||
// becuase we are triggering a lot of runs at once, and each one is trying to lock the parent run.
|
||||
// by forcing sequential, we are only ever locking the parent run for a single run at a time.
|
||||
this._batchProcessingStrategy = "sequential";
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV3RequestBody,
|
||||
options: BatchTriggerTaskServiceOptions = {}
|
||||
): Promise<BatchTriggerTaskV3Response> {
|
||||
try {
|
||||
return await this.traceWithEnv<BatchTriggerTaskV3Response>(
|
||||
"call()",
|
||||
environment,
|
||||
async (span) => {
|
||||
const { friendlyId } = await mintBatchFriendlyId({
|
||||
environment: {
|
||||
organizationId: environment.organizationId,
|
||||
id: environment.id,
|
||||
orgFeatureFlags: environment.organization.featureFlags,
|
||||
},
|
||||
parentRunFriendlyId: body.parentRunId,
|
||||
});
|
||||
|
||||
span.setAttribute("batchId", friendlyId);
|
||||
|
||||
const payloadPacket = await this.#handlePayloadPacket(
|
||||
body.items,
|
||||
`batch/${friendlyId}`,
|
||||
environment
|
||||
);
|
||||
|
||||
const batch = await this.#createAndProcessBatchTaskRun(
|
||||
friendlyId,
|
||||
payloadPacket,
|
||||
environment,
|
||||
body,
|
||||
options
|
||||
);
|
||||
|
||||
if (!batch) {
|
||||
throw new Error("Failed to create batch");
|
||||
}
|
||||
|
||||
return {
|
||||
id: batch.friendlyId,
|
||||
isCached: false,
|
||||
idempotencyKey: batch.idempotencyKey ?? undefined,
|
||||
runCount: body.items.length,
|
||||
};
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
// Detect a prisma transaction Unique constraint violation
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.debug("RunEngineBatchTrigger: Prisma transaction 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 batch trigger with a one-time use token as it has already been used."
|
||||
);
|
||||
} else {
|
||||
throw new ServiceValidationError(
|
||||
"Cannot batch trigger as it has already been triggered with the same idempotency key."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async #createAndProcessBatchTaskRun(
|
||||
batchId: string,
|
||||
payloadPacket: IOPacket,
|
||||
environment: AuthenticatedEnvironment,
|
||||
body: BatchTriggerTaskV2RequestBody,
|
||||
options: BatchTriggerTaskServiceOptions = {}
|
||||
) {
|
||||
// BatchTaskRun.runtimeEnvironmentId no longer has an FK into RuntimeEnvironment;
|
||||
// validate env existence app-side (covers both create arms below).
|
||||
await controlPlaneResolver.assertEnvExists(environment.id);
|
||||
|
||||
if (body.items.length <= ASYNC_BATCH_PROCESS_SIZE_THRESHOLD) {
|
||||
const batch = await this._engine.runStore.createBatchTaskRun({
|
||||
id: BatchId.fromFriendlyId(batchId),
|
||||
friendlyId: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
runCount: body.items.length,
|
||||
runIds: [],
|
||||
payload: payloadPacket.data,
|
||||
payloadType: payloadPacket.dataType,
|
||||
options,
|
||||
batchVersion: "runengine:v1",
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
});
|
||||
|
||||
this.onBatchTaskRunCreated.post(batch);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await this.#processBatchTaskRunItems({
|
||||
batch,
|
||||
environment,
|
||||
currentIndex: 0,
|
||||
batchSize: PROCESSING_BATCH_SIZE,
|
||||
items: body.items,
|
||||
options,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
switch (result.status) {
|
||||
case "COMPLETE": {
|
||||
logger.debug("[RunEngineBatchTrigger][call] Batch inline processing complete", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: 0,
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
case "INCOMPLETE": {
|
||||
logger.debug("[RunEngineBatchTrigger][call] Batch inline processing incomplete", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
});
|
||||
|
||||
// If processing inline does not finish for some reason, enqueue processing the rest of the batch
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: "0",
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: PROCESSING_BATCH_SIZE,
|
||||
},
|
||||
attemptCount: 0,
|
||||
strategy: "sequential",
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
case "ERROR": {
|
||||
logger.error("[RunEngineBatchTrigger][call] Batch inline processing error", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
error: result.error,
|
||||
});
|
||||
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: "0",
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: PROCESSING_BATCH_SIZE,
|
||||
},
|
||||
attemptCount: 0,
|
||||
strategy: "sequential",
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const batch = await this._engine.runStore.createBatchTaskRun({
|
||||
id: BatchId.fromFriendlyId(batchId),
|
||||
friendlyId: batchId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
runCount: body.items.length,
|
||||
runIds: [],
|
||||
payload: payloadPacket.data,
|
||||
payloadType: payloadPacket.dataType,
|
||||
options,
|
||||
batchVersion: "runengine:v1",
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
});
|
||||
|
||||
this.onBatchTaskRunCreated.post(batch);
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
switch (this._batchProcessingStrategy) {
|
||||
case "sequential": {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: batchId,
|
||||
range: { start: 0, count: PROCESSING_BATCH_SIZE },
|
||||
attemptCount: 0,
|
||||
strategy: this._batchProcessingStrategy,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
case "parallel": {
|
||||
const ranges = Array.from({
|
||||
length: Math.ceil(body.items.length / PROCESSING_BATCH_SIZE),
|
||||
}).map((_, index) => ({
|
||||
start: index * PROCESSING_BATCH_SIZE,
|
||||
count: PROCESSING_BATCH_SIZE,
|
||||
}));
|
||||
|
||||
await Promise.all(
|
||||
ranges.map((range, index) =>
|
||||
this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: `${index}`,
|
||||
range,
|
||||
attemptCount: 0,
|
||||
strategy: this._batchProcessingStrategy,
|
||||
parentRunId: body.parentRunId,
|
||||
resumeParentOnCompletion: body.resumeParentOnCompletion,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return batch;
|
||||
}
|
||||
}
|
||||
|
||||
async #enqueueBatchTaskRun(options: BatchProcessingOptions) {
|
||||
await batchTriggerWorker.enqueue({
|
||||
id: `RunEngineBatchTriggerService.process:${options.batchId}:${options.processingId}`,
|
||||
job: "runengine.processBatchTaskRun",
|
||||
payload: options,
|
||||
});
|
||||
}
|
||||
|
||||
// This is the function that the worker will call
|
||||
async processBatchTaskRun(options: BatchProcessingOptions) {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Processing batch", {
|
||||
options,
|
||||
});
|
||||
|
||||
const $attemptCount = options.attemptCount + 1;
|
||||
|
||||
if ($attemptCount > MAX_ATTEMPTS) {
|
||||
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Max attempts reached", {
|
||||
options,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
// You might want to update the batch status to failed here
|
||||
return;
|
||||
}
|
||||
|
||||
const batch = await this._engine.runStore.findBatchTaskRunById(options.batchId);
|
||||
|
||||
if (!batch) {
|
||||
return;
|
||||
}
|
||||
|
||||
// BatchTaskRun -> RuntimeEnvironment FK is dropped; resolve the env from the scalar id.
|
||||
const environment = await findEnvironmentById(batch.runtimeEnvironmentId);
|
||||
if (!environment) {
|
||||
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Environment not found", {
|
||||
batchId: batch.id,
|
||||
runtimeEnvironmentId: batch.runtimeEnvironmentId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.range.start >= batch.runCount) {
|
||||
logger.debug(
|
||||
"[RunEngineBatchTrigger][processBatchTaskRun] currentIndex is greater than runCount",
|
||||
{
|
||||
options,
|
||||
batchId: batch.friendlyId,
|
||||
runCount: batch.runCount,
|
||||
attemptCount: $attemptCount,
|
||||
}
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadPacket = await downloadPacketFromObjectStore(
|
||||
{
|
||||
data: batch.payload ?? undefined,
|
||||
dataType: batch.payloadType,
|
||||
},
|
||||
environment
|
||||
);
|
||||
|
||||
const payload = await parsePacket(payloadPacket);
|
||||
|
||||
if (!payload) {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Failed to parse payload", {
|
||||
options,
|
||||
batchId: batch.friendlyId,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
throw new Error("Failed to parse payload");
|
||||
}
|
||||
|
||||
// Skip zod parsing
|
||||
const $payload = payload as BatchTriggerTaskV2RequestBody["items"];
|
||||
const $options = batch.options as BatchTriggerTaskServiceOptions;
|
||||
|
||||
const result = await this.#processBatchTaskRunItems({
|
||||
batch,
|
||||
environment,
|
||||
currentIndex: options.range.start,
|
||||
batchSize: options.range.count,
|
||||
items: $payload,
|
||||
options: $options,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
switch (result.status) {
|
||||
case "COMPLETE": {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Batch processing complete", {
|
||||
options,
|
||||
batchId: batch.friendlyId,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
case "INCOMPLETE": {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Batch processing incomplete", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
// Only enqueue the next batch task run if the strategy is sequential
|
||||
// if the strategy is parallel, we will already have enqueued the next batch task run
|
||||
if (options.strategy === "sequential") {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: options.processingId,
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: options.range.count,
|
||||
},
|
||||
attemptCount: 0,
|
||||
strategy: options.strategy,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
case "ERROR": {
|
||||
logger.error("[RunEngineBatchTrigger][processBatchTaskRun] Batch processing error", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: result.workingIndex,
|
||||
error: result.error,
|
||||
attemptCount: $attemptCount,
|
||||
});
|
||||
|
||||
// if the strategy is sequential, we will requeue processing with a count of the PROCESSING_BATCH_SIZE
|
||||
// if the strategy is parallel, we will requeue processing with a range starting at the workingIndex and a count that is the remainder of this "slice" of the batch
|
||||
if (options.strategy === "sequential") {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: options.processingId,
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
count: options.range.count, // This will be the same as the original count
|
||||
},
|
||||
attemptCount: $attemptCount,
|
||||
strategy: options.strategy,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
});
|
||||
} else {
|
||||
await this.#enqueueBatchTaskRun({
|
||||
batchId: batch.id,
|
||||
processingId: options.processingId,
|
||||
range: {
|
||||
start: result.workingIndex,
|
||||
// This will be the remainder of the slice
|
||||
// for example if the original range was 0-50 and the workingIndex is 25, the new range will be 25-25
|
||||
// if the original range was 51-100 and the workingIndex is 75, the new range will be 75-25
|
||||
count: options.range.count - result.workingIndex - options.range.start,
|
||||
},
|
||||
attemptCount: $attemptCount,
|
||||
strategy: options.strategy,
|
||||
parentRunId: options.parentRunId,
|
||||
resumeParentOnCompletion: options.resumeParentOnCompletion,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #processBatchTaskRunItems({
|
||||
batch,
|
||||
environment,
|
||||
currentIndex,
|
||||
batchSize,
|
||||
items,
|
||||
options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
}: {
|
||||
batch: BatchTaskRun;
|
||||
environment: AuthenticatedEnvironment;
|
||||
currentIndex: number;
|
||||
batchSize: number;
|
||||
items: BatchTriggerTaskV2RequestBody["items"];
|
||||
options?: BatchTriggerTaskServiceOptions;
|
||||
parentRunId?: string | undefined;
|
||||
resumeParentOnCompletion?: boolean | undefined;
|
||||
}): Promise<
|
||||
| { status: "COMPLETE" }
|
||||
| { status: "INCOMPLETE"; workingIndex: number }
|
||||
| { status: "ERROR"; error: string; workingIndex: number }
|
||||
> {
|
||||
// Grab the next PROCESSING_BATCH_SIZE items
|
||||
const itemsToProcess = items.slice(currentIndex, currentIndex + batchSize);
|
||||
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] Processing batch items", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex,
|
||||
runCount: batch.runCount,
|
||||
});
|
||||
|
||||
let workingIndex = currentIndex;
|
||||
|
||||
let runIds: string[] = [];
|
||||
|
||||
const triggerFailedTaskService = new TriggerFailedTaskService({
|
||||
prisma: this._prisma,
|
||||
engine: this._engine,
|
||||
replicaPrisma: this._replica,
|
||||
});
|
||||
|
||||
for (const item of itemsToProcess) {
|
||||
let runFriendlyId: string | null = null;
|
||||
|
||||
try {
|
||||
const run = await this.#processBatchTaskRunItem({
|
||||
batch,
|
||||
environment,
|
||||
item,
|
||||
currentIndex: workingIndex,
|
||||
options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
if (run) {
|
||||
runFriendlyId = run.friendlyId;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Trigger failed - will try to create pre-failed run below
|
||||
runFriendlyId = null;
|
||||
}
|
||||
|
||||
if (!runFriendlyId) {
|
||||
const errorMessage =
|
||||
"Trigger failed for batch item (queue limit, entitlement, or validation error)";
|
||||
logger.debug(
|
||||
"[RunEngineBatchTrigger][processBatchTaskRun] Item trigger failed, creating pre-failed run",
|
||||
{
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: workingIndex,
|
||||
task: item.task,
|
||||
}
|
||||
);
|
||||
|
||||
const failedRunId = await triggerFailedTaskService.call({
|
||||
taskId: item.task,
|
||||
environment,
|
||||
payload: item.payload,
|
||||
payloadType: item.options?.payloadType,
|
||||
errorMessage,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
batch: { id: batch.id, index: workingIndex },
|
||||
// Anchor the pre-failed run on the BATCH's residency (same as the happy path) so it
|
||||
// co-resides with its BatchTaskRun row regardless of a mid-batch mint-flag flip.
|
||||
runFriendlyId: mintAnchoredRunFriendlyId(batch.friendlyId, item.options?.region),
|
||||
options: item.options as Record<string, unknown>,
|
||||
traceContext: options?.traceContext as Record<string, unknown> | undefined,
|
||||
spanParentAsLink: options?.spanParentAsLink,
|
||||
errorCode: TaskRunErrorCodes.BATCH_ITEM_COULD_NOT_TRIGGER,
|
||||
});
|
||||
|
||||
if (failedRunId) {
|
||||
runFriendlyId = failedRunId;
|
||||
} else {
|
||||
logger.error(
|
||||
"[RunEngineBatchTrigger][processBatchTaskRun] Failed to create pre-failed run",
|
||||
{
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex: workingIndex,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
status: "ERROR",
|
||||
error: "Could not trigger item and could not create pre-failed run",
|
||||
workingIndex,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
runIds.push(runFriendlyId);
|
||||
workingIndex++;
|
||||
}
|
||||
|
||||
const updatedBatch = await this._engine.runStore.updateBatchTaskRun({
|
||||
where: { id: batch.id },
|
||||
data: {
|
||||
runIds: {
|
||||
push: runIds,
|
||||
},
|
||||
processingJobsCount: {
|
||||
increment: runIds.length,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
processingJobsCount: true,
|
||||
runCount: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (updatedBatch.processingJobsCount >= updatedBatch.runCount) {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRun] All runs created", {
|
||||
batchId: batch.friendlyId,
|
||||
processingJobsCount: updatedBatch.processingJobsCount,
|
||||
runCount: updatedBatch.runCount,
|
||||
workingIndex,
|
||||
});
|
||||
|
||||
//if all the runs were idempotent, it's possible the batch is already completed
|
||||
await this._engine.tryCompleteBatch({ batchId: batch.id });
|
||||
}
|
||||
|
||||
// if there are more items to process, requeue the batch
|
||||
if (workingIndex < batch.runCount) {
|
||||
return { status: "INCOMPLETE", workingIndex };
|
||||
}
|
||||
|
||||
return { status: "COMPLETE" };
|
||||
}
|
||||
|
||||
async #processBatchTaskRunItem({
|
||||
batch,
|
||||
environment,
|
||||
item,
|
||||
currentIndex,
|
||||
options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
}: {
|
||||
batch: BatchTaskRun;
|
||||
environment: AuthenticatedEnvironment;
|
||||
item: BatchTriggerTaskV2RequestBody["items"][number];
|
||||
currentIndex: number;
|
||||
options?: BatchTriggerTaskServiceOptions;
|
||||
parentRunId: string | undefined;
|
||||
resumeParentOnCompletion: boolean | undefined;
|
||||
}) {
|
||||
logger.debug("[RunEngineBatchTrigger][processBatchTaskRunItem] Processing item", {
|
||||
batchId: batch.friendlyId,
|
||||
currentIndex,
|
||||
});
|
||||
|
||||
const triggerTaskService = new TriggerTaskService();
|
||||
|
||||
// Anchor the item's mint on the BATCH's own friendlyId (not a fresh per-org flag read) so
|
||||
// an org's mint flag flipping between batch creation and this (possibly much later,
|
||||
// worker-processed) item never splits the item from its BatchTaskRun row. See
|
||||
// mintAnchoredRunFriendlyId.server.ts.
|
||||
const runFriendlyId = mintAnchoredRunFriendlyId(batch.friendlyId, item.options?.region);
|
||||
|
||||
const result = await triggerTaskService.call(
|
||||
item.task,
|
||||
environment,
|
||||
{
|
||||
...item,
|
||||
options: {
|
||||
...item.options,
|
||||
parentRunId,
|
||||
resumeParentOnCompletion,
|
||||
parentBatch: batch.id,
|
||||
},
|
||||
},
|
||||
{
|
||||
triggerVersion: options?.triggerVersion,
|
||||
traceContext: options?.traceContext,
|
||||
spanParentAsLink: options?.spanParentAsLink,
|
||||
batchId: batch.id,
|
||||
batchIndex: currentIndex,
|
||||
runFriendlyId,
|
||||
realtimeStreamsVersion: options?.realtimeStreamsVersion,
|
||||
triggerSource: options?.triggerSource ?? "api",
|
||||
triggerAction: options?.triggerAction ?? "trigger",
|
||||
},
|
||||
"V2"
|
||||
);
|
||||
|
||||
return result
|
||||
? {
|
||||
friendlyId: result.run.friendlyId,
|
||||
}
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async #handlePayloadPacket(
|
||||
payload: any,
|
||||
pathPrefix: string,
|
||||
environment: AuthenticatedEnvironment
|
||||
) {
|
||||
return await startActiveSpan("handlePayloadPacket()", async (span) => {
|
||||
const packet = { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
|
||||
if (!packet.data) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const { needsOffloading } = packetRequiresOffloading(
|
||||
packet,
|
||||
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
|
||||
);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const filename = `${pathPrefix}/payload.json`;
|
||||
|
||||
const uploadedFilename = await uploadPacketToObjectStore(
|
||||
filename,
|
||||
packet.data,
|
||||
packet.dataType,
|
||||
environment
|
||||
);
|
||||
|
||||
return {
|
||||
data: uploadedFilename,
|
||||
dataType: "application/store",
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,837 @@
|
||||
import {
|
||||
type StreamBatchItemsResponse,
|
||||
BatchItemNDJSON as BatchItemNDJSONSchema,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { BatchId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { BatchItem, RunEngine } from "@internal/run-engine";
|
||||
import pMap from "p-map";
|
||||
import type { BatchTaskRunStatus } from "@trigger.dev/database";
|
||||
import { prisma, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError, WithRunEngine } from "../../v3/services/baseService.server";
|
||||
import { BatchPayloadProcessor } from "../concerns/batchPayloads.server";
|
||||
|
||||
/**
|
||||
* Phase 2 retry idempotency check.
|
||||
*
|
||||
* Returns true when the batch is in a state that means the Phase 2 stream's
|
||||
* job has already been done — every item has a TaskRun record (real or
|
||||
* pre-failed) for the customer to monitor. A retry, or the original call
|
||||
* racing against a fast-completing BatchQueue, should return sealed:true
|
||||
* in these states so the SDK stops retrying.
|
||||
*
|
||||
* Three "work is done" shapes:
|
||||
* - status moved out of PENDING into PROCESSING/COMPLETED/PARTIAL_FAILED
|
||||
* (PROCESSING via our seal, COMPLETED via tryCompleteBatch, PARTIAL_FAILED
|
||||
* via the V2 batchCompletionCallback).
|
||||
* - status stuck at PENDING but `sealed=true`: another concurrent
|
||||
* streamBatchItems call sealed the batch and then the callback's
|
||||
* happy-path branch reset status to PENDING ("all runs created").
|
||||
* - status stuck at PENDING with `sealed=false` but `processingCompletedAt`
|
||||
* set: the cleanup-race. BatchQueue rushed through all items, callback
|
||||
* fired (setting processingCompletedAt), cleanup deleted the Redis
|
||||
* metadata — all before our service got the chance to seal. The work
|
||||
* is done; the discriminator is processingCompletedAt which is set
|
||||
* exclusively by the V2 completion callback.
|
||||
*
|
||||
* ABORTED is excluded — it means ZERO TaskRun records were created (every
|
||||
* per-item attempt failed AND the pre-failed-TaskRun fallback also failed,
|
||||
* or queue-overload on every item). The customer has nothing to monitor
|
||||
* at the run level, so the trigger call must throw to give their retry/
|
||||
* error handling a chance to create a fresh batch.
|
||||
*/
|
||||
export function isIdempotentRetrySuccess(
|
||||
status: BatchTaskRunStatus | null | undefined,
|
||||
sealed: boolean | null | undefined,
|
||||
processingCompletedAt: Date | null | undefined
|
||||
): boolean {
|
||||
return (
|
||||
status === "PROCESSING" ||
|
||||
status === "COMPLETED" ||
|
||||
status === "PARTIAL_FAILED" ||
|
||||
(status === "PENDING" && (sealed === true || processingCompletedAt != null))
|
||||
);
|
||||
}
|
||||
|
||||
export type StreamBatchItemsServiceOptions = {
|
||||
maxItemBytes: number;
|
||||
/** Max items processed concurrently. The route wires this to STREAMING_BATCH_INGEST_CONCURRENCY. */
|
||||
concurrency: number;
|
||||
};
|
||||
|
||||
export type OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED";
|
||||
index: number;
|
||||
task: string;
|
||||
actualSize: number;
|
||||
maxSize: number;
|
||||
};
|
||||
|
||||
export type StreamBatchItemsServiceConstructorOptions = {
|
||||
prisma?: PrismaClientOrTransaction;
|
||||
engine?: RunEngine;
|
||||
/** Override the payload processor (used in tests to observe ingest concurrency). */
|
||||
payloadProcessor?: BatchPayloadProcessor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stream Batch Items Service (Phase 2 of 2-phase batch API).
|
||||
*
|
||||
* This service handles Phase 2 of the streaming batch API:
|
||||
* 1. Validates batch exists and is in PENDING status
|
||||
* 2. Processes NDJSON stream item by item
|
||||
* 3. Calls engine.enqueueBatchItem() for each item
|
||||
* 4. Tracks accepted/deduplicated counts
|
||||
* 5. On completion: validates count, seals the batch
|
||||
*
|
||||
* The service is designed for streaming and processes items as they arrive,
|
||||
* providing backpressure through the async iterator pattern.
|
||||
*/
|
||||
export class StreamBatchItemsService extends WithRunEngine {
|
||||
private readonly payloadProcessor: BatchPayloadProcessor;
|
||||
|
||||
constructor(opts: StreamBatchItemsServiceConstructorOptions = {}) {
|
||||
super({ prisma: opts.prisma ?? prisma, engine: opts.engine });
|
||||
this.payloadProcessor = opts.payloadProcessor ?? new BatchPayloadProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a batch friendly ID to its internal ID format.
|
||||
* Throws a ServiceValidationError with 400 status if the ID is malformed.
|
||||
*/
|
||||
private parseBatchFriendlyId(friendlyId: string): string {
|
||||
try {
|
||||
return BatchId.fromFriendlyId(friendlyId);
|
||||
} catch {
|
||||
throw new ServiceValidationError(`Invalid batchFriendlyId: ${friendlyId}`, 400);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a stream of batch items from an async iterator.
|
||||
* Each item is validated and enqueued to the BatchQueue.
|
||||
* The batch is sealed when the stream completes.
|
||||
*/
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
batchFriendlyId: string,
|
||||
itemsIterator: AsyncIterable<unknown>,
|
||||
options: StreamBatchItemsServiceOptions
|
||||
): Promise<StreamBatchItemsResponse> {
|
||||
return this.traceWithEnv<StreamBatchItemsResponse>(
|
||||
"streamBatchItems()",
|
||||
environment,
|
||||
async (span) => {
|
||||
span.setAttribute("batchId", batchFriendlyId);
|
||||
|
||||
// Convert friendly ID to internal ID
|
||||
const batchId = this.parseBatchFriendlyId(batchFriendlyId);
|
||||
|
||||
// Validate batch exists and belongs to this environment. Routed by batch id so a
|
||||
// run-ops id (NEW-resident) batch is found on the owning DB; the env-ownership check that
|
||||
// was in the where clause is enforced app-side below.
|
||||
const batch = await this._engine.runStore.findBatchTaskRunById(batchId);
|
||||
|
||||
if (!batch || batch.runtimeEnvironmentId !== environment.id) {
|
||||
throw new ServiceValidationError(`Batch ${batchFriendlyId} not found`);
|
||||
}
|
||||
|
||||
if (isIdempotentRetrySuccess(batch.status, batch.sealed, batch.processingCompletedAt)) {
|
||||
logger.info("Batch already sealed/completed - treating Phase 2 retry as success", {
|
||||
batchId: batchFriendlyId,
|
||||
batchSealed: batch.sealed,
|
||||
batchStatus: batch.status,
|
||||
processingCompletedAt: batch.processingCompletedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted: 0,
|
||||
itemsDeduplicated: 0,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (batch.status !== "PENDING") {
|
||||
// ABORTED or any other unexpected non-PENDING state — surface as an error.
|
||||
// For ABORTED specifically, throwing is required so the customer's
|
||||
// batchTrigger() retries (a new batch) can recreate the runs.
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is not in PENDING status (current: ${batch.status})`
|
||||
);
|
||||
}
|
||||
|
||||
// Process items from the stream with bounded concurrency.
|
||||
//
|
||||
// Ordering and idempotency do NOT depend on processing order:
|
||||
// - The BatchQueue derives run order from each item's index
|
||||
// (enqueue timestamp = batch.createdAt + itemIndex), not enqueue order.
|
||||
// - enqueueBatchItem() dedups atomically per index.
|
||||
// We cap concurrency to bound peak in-flight memory (≈ concurrency ×
|
||||
// maxItemBytes) and to keep backpressure on the request body stream.
|
||||
// p-map pulls lazily from the async iterator — at most `concurrency`
|
||||
// items are read and in flight at once. stopOnError aborts ingestion on
|
||||
// the first failure (the batch is left unsealed; the SDK's retry
|
||||
// re-streams and dedups already-enqueued items).
|
||||
const outcomes = await pMap(
|
||||
itemsIterator,
|
||||
(rawItem) => this.#processItem(rawItem, batchId, environment, batch.runCount),
|
||||
{ concurrency: options.concurrency, stopOnError: true }
|
||||
);
|
||||
|
||||
let itemsAccepted = 0;
|
||||
let itemsDeduplicated = 0;
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome === "accepted") {
|
||||
itemsAccepted++;
|
||||
} else {
|
||||
itemsDeduplicated++;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual enqueued count from Redis
|
||||
const enqueuedCount = await this._engine.getBatchEnqueuedCount(batchId);
|
||||
|
||||
// Validate we received the expected number of items
|
||||
if (enqueuedCount !== batch.runCount) {
|
||||
// The batch queue consumers may have already processed all items and
|
||||
// cleaned up the Redis keys before we got here. This happens when all
|
||||
// runs complete fast enough that cleanup() deletes the enqueuedItemsKey
|
||||
// before we read it — typically when the last item executes in the
|
||||
// milliseconds between the loop ending and getBatchEnqueuedCount() being called.
|
||||
// Check both sealed (sealed by this endpoint on a concurrent request) and
|
||||
// COMPLETED (sealed by the BatchQueue completion path before we got here).
|
||||
const currentBatch = await this._engine.runStore.findBatchTaskRunById(batchId);
|
||||
|
||||
if (
|
||||
isIdempotentRetrySuccess(
|
||||
currentBatch?.status,
|
||||
currentBatch?.sealed,
|
||||
currentBatch?.processingCompletedAt
|
||||
)
|
||||
) {
|
||||
logger.info("Batch already sealed before count check (fast completion)", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
batchStatus: currentBatch?.status,
|
||||
processingCompletedAt: currentBatch?.processingCompletedAt,
|
||||
});
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentBatch?.status === "ABORTED") {
|
||||
// Zero TaskRuns exist — the count-mismatch sealed:false semantics
|
||||
// ("retry with missing items") would mislead the SDK. Throw so the
|
||||
// customer's batchTrigger() retry creates a fresh batch.
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is not in PENDING status (current: ABORTED)`
|
||||
);
|
||||
}
|
||||
|
||||
logger.warn("Batch item count mismatch", {
|
||||
batchId: batchFriendlyId,
|
||||
expected: batch.runCount,
|
||||
received: enqueuedCount,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
});
|
||||
|
||||
// Don't seal the batch if count doesn't match
|
||||
// Return sealed: false so client knows to retry with missing items
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: false,
|
||||
enqueuedCount,
|
||||
expectedCount: batch.runCount,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Seal the batch - use conditional update to prevent TOCTOU race
|
||||
// Another concurrent request may have already sealed this batch
|
||||
const now = new Date();
|
||||
const sealResult = await this._engine.runStore.updateManyBatchTaskRun({
|
||||
where: {
|
||||
id: batchId,
|
||||
sealed: false,
|
||||
status: "PENDING",
|
||||
},
|
||||
data: {
|
||||
sealed: true,
|
||||
sealedAt: now,
|
||||
status: "PROCESSING",
|
||||
processingStartedAt: now,
|
||||
},
|
||||
});
|
||||
|
||||
// Check if we won the race to seal the batch
|
||||
if (sealResult.count === 0) {
|
||||
// The conditional update failed because the batch was no longer in
|
||||
// PENDING status. Re-query to determine which path got there first:
|
||||
// - A concurrent streaming request already sealed and moved it to
|
||||
// PROCESSING.
|
||||
// - The BatchQueue completion path finished all runs and set it to
|
||||
// COMPLETED (without setting sealed=true — that's this endpoint's
|
||||
// job). This window exists between completionCallback (which calls
|
||||
// tryCompleteBatch) and cleanup() in BatchQueue — see
|
||||
// batch-queue/index.ts.
|
||||
// Either way the goal — a durable batch that the SDK stops retrying —
|
||||
// has been achieved, so we return sealed: true.
|
||||
const currentBatch = await this._engine.runStore.findBatchTaskRunById(batchId);
|
||||
|
||||
if (
|
||||
isIdempotentRetrySuccess(
|
||||
currentBatch?.status,
|
||||
currentBatch?.sealed,
|
||||
currentBatch?.processingCompletedAt
|
||||
)
|
||||
) {
|
||||
logger.info("Batch already sealed/completed by concurrent path", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
envId: environment.id,
|
||||
batchStatus: currentBatch?.status,
|
||||
batchSealed: currentBatch?.sealed,
|
||||
processingCompletedAt: currentBatch?.processingCompletedAt,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
span.setAttribute("sealedByConcurrentRequest", true);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Batch is in an unexpected state - fail with error
|
||||
const actualStatus = currentBatch?.status ?? "unknown";
|
||||
const actualSealed = currentBatch?.sealed ?? "unknown";
|
||||
logger.error("Batch seal race condition: unexpected state", {
|
||||
batchId: batchFriendlyId,
|
||||
expectedStatus: "PENDING",
|
||||
actualStatus,
|
||||
expectedSealed: false,
|
||||
actualSealed,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
throw new ServiceValidationError(
|
||||
`Batch ${batchFriendlyId} is in unexpected state (status: ${actualStatus}, sealed: ${actualSealed}). Cannot seal batch.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("Batch sealed and ready for processing", {
|
||||
batchId: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
totalEnqueued: enqueuedCount,
|
||||
envId: environment.id,
|
||||
});
|
||||
|
||||
span.setAttribute("itemsAccepted", itemsAccepted);
|
||||
span.setAttribute("itemsDeduplicated", itemsDeduplicated);
|
||||
|
||||
return {
|
||||
id: batchFriendlyId,
|
||||
itemsAccepted,
|
||||
itemsDeduplicated,
|
||||
sealed: true,
|
||||
runCount: batch.runCount,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a single streamed batch item: validate it, offload its payload to
|
||||
* object storage if oversized, and enqueue it. Returns whether the item was
|
||||
* newly enqueued ("accepted") or was a duplicate ("deduplicated"). Throws
|
||||
* ServiceValidationError for invalid items, which aborts the stream.
|
||||
*
|
||||
* Safe to run concurrently: enqueueBatchItem() is atomic and order-independent
|
||||
* per item index, and each item carries its own index (real items from the
|
||||
* SDK; oversized markers are stamped by the NDJSON parser).
|
||||
*/
|
||||
async #processItem(
|
||||
rawItem: unknown,
|
||||
batchId: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
runCount: number
|
||||
): Promise<"accepted" | "deduplicated"> {
|
||||
// Oversized item marker emitted by the NDJSON parser
|
||||
if (rawItem && typeof rawItem === "object" && "__batchItemError" in rawItem) {
|
||||
const marker = rawItem as OversizedItemMarker;
|
||||
|
||||
// Same out-of-range guard as normal items: an oversized item with an
|
||||
// out-of-range index must 4xx rather than create a stray pre-failed run.
|
||||
if (marker.index >= runCount) {
|
||||
throw new ServiceValidationError(
|
||||
`Item index ${marker.index} exceeds batch runCount ${runCount}`
|
||||
);
|
||||
}
|
||||
|
||||
const errorMessage = `Batch item payload is too large (${(marker.actualSize / 1024).toFixed(
|
||||
1
|
||||
)} KB). Maximum allowed size is ${(marker.maxSize / 1024).toFixed(
|
||||
1
|
||||
)} KB. Reduce the payload size or offload large data to external storage.`;
|
||||
|
||||
// Enqueue with __error metadata - processItemCallback will detect this
|
||||
// and use TriggerFailedTaskService to create a pre-failed run
|
||||
const batchItem: BatchItem = {
|
||||
task: marker.task,
|
||||
payload: "{}",
|
||||
payloadType: "application/json",
|
||||
options: {
|
||||
__error: errorMessage,
|
||||
__errorCode: "PAYLOAD_TOO_LARGE",
|
||||
},
|
||||
};
|
||||
|
||||
const result = await this._engine.enqueueBatchItem(
|
||||
batchId,
|
||||
environment.id,
|
||||
marker.index,
|
||||
batchItem
|
||||
);
|
||||
|
||||
return result.enqueued ? "accepted" : "deduplicated";
|
||||
}
|
||||
|
||||
// Parse and validate the item
|
||||
const parseResult = BatchItemNDJSONSchema.safeParse(rawItem);
|
||||
if (!parseResult.success) {
|
||||
const rawIndex = (rawItem as { index?: unknown } | null)?.index;
|
||||
const where = typeof rawIndex === "number" ? `index ${rawIndex}` : "unknown index";
|
||||
throw new ServiceValidationError(`Invalid item at ${where}: ${parseResult.error.message}`);
|
||||
}
|
||||
|
||||
const item = parseResult.data;
|
||||
|
||||
// Validate index is within expected range
|
||||
if (item.index >= runCount) {
|
||||
throw new ServiceValidationError(
|
||||
`Item index ${item.index} exceeds batch runCount ${runCount}`
|
||||
);
|
||||
}
|
||||
|
||||
// Get the original payload type
|
||||
const originalPayloadType = (item.options?.payloadType as string) ?? "application/json";
|
||||
|
||||
// Process payload - offload to object storage if it exceeds threshold
|
||||
const processedPayload = await this.payloadProcessor.process(
|
||||
item.payload,
|
||||
originalPayloadType,
|
||||
batchId,
|
||||
item.index,
|
||||
environment
|
||||
);
|
||||
|
||||
// Convert to BatchItem format with potentially offloaded payload
|
||||
const batchItem: BatchItem = {
|
||||
task: item.task,
|
||||
payload: processedPayload.payload,
|
||||
payloadType: processedPayload.payloadType,
|
||||
options: item.options,
|
||||
};
|
||||
|
||||
// Enqueue the item
|
||||
const result = await this._engine.enqueueBatchItem(
|
||||
batchId,
|
||||
environment.id,
|
||||
item.index,
|
||||
batchItem
|
||||
);
|
||||
|
||||
return result.enqueued ? "accepted" : "deduplicated";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract `index` and `task` from raw JSON bytes without decoding the full line.
|
||||
* Scans at most 512 bytes, tracking JSON nesting depth to only match top-level keys.
|
||||
*/
|
||||
export function extractIndexAndTask(bytes: Uint8Array): { index: number; task: string } {
|
||||
let index = -1;
|
||||
let task = "unknown";
|
||||
let depth = 0;
|
||||
let foundIndex = false;
|
||||
let foundTask = false;
|
||||
const limit = Math.min(bytes.byteLength, 512);
|
||||
|
||||
const QUOTE = 0x22; // "
|
||||
const COLON = 0x3a; // :
|
||||
const LBRACE = 0x7b; // {
|
||||
const RBRACE = 0x7d; // }
|
||||
const LBRACKET = 0x5b; // [
|
||||
const RBRACKET = 0x5d; // ]
|
||||
const BACKSLASH = 0x5c; // \
|
||||
|
||||
// Byte patterns for "index" and "task" (without quotes)
|
||||
const INDEX_BYTES = [0x69, 0x6e, 0x64, 0x65, 0x78]; // index
|
||||
const TASK_BYTES = [0x74, 0x61, 0x73, 0x6b]; // task
|
||||
|
||||
let i = 0;
|
||||
while (i < limit && !(foundIndex && foundTask)) {
|
||||
const b = bytes[i];
|
||||
|
||||
if (b === LBRACE || b === LBRACKET) {
|
||||
depth++;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (b === RBRACE || b === RBRACKET) {
|
||||
depth--;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only match keys at depth 1 (top-level object)
|
||||
if (b === QUOTE && depth === 1) {
|
||||
// Read the key inside quotes
|
||||
const keyStart = i + 1;
|
||||
let keyEnd = keyStart;
|
||||
while (keyEnd < limit && bytes[keyEnd] !== QUOTE) {
|
||||
if (bytes[keyEnd] === BACKSLASH) keyEnd++; // skip escaped char
|
||||
keyEnd++;
|
||||
}
|
||||
|
||||
const keyLen = keyEnd - keyStart;
|
||||
|
||||
// Check if this key matches "index" or "task"
|
||||
const isIndex =
|
||||
!foundIndex &&
|
||||
keyLen === INDEX_BYTES.length &&
|
||||
INDEX_BYTES.every((b, j) => bytes[keyStart + j] === b);
|
||||
const isTask =
|
||||
!foundTask &&
|
||||
keyLen === TASK_BYTES.length &&
|
||||
TASK_BYTES.every((b, j) => bytes[keyStart + j] === b);
|
||||
|
||||
if (isIndex || isTask) {
|
||||
// Skip past closing quote and find colon
|
||||
let pos = keyEnd + 1;
|
||||
while (pos < limit && bytes[pos] !== COLON) pos++;
|
||||
pos++; // skip colon
|
||||
// Skip whitespace
|
||||
while (pos < limit && (bytes[pos] === 0x20 || bytes[pos] === 0x09)) pos++;
|
||||
|
||||
if (isIndex) {
|
||||
// Parse digits
|
||||
let num = 0;
|
||||
let hasDigit = false;
|
||||
while (pos < limit && bytes[pos] >= 0x30 && bytes[pos] <= 0x39) {
|
||||
num = num * 10 + (bytes[pos] - 0x30);
|
||||
hasDigit = true;
|
||||
pos++;
|
||||
}
|
||||
if (hasDigit) {
|
||||
index = num;
|
||||
foundIndex = true;
|
||||
}
|
||||
} else {
|
||||
// Parse quoted string value
|
||||
if (pos < limit && bytes[pos] === QUOTE) {
|
||||
const valStart = pos + 1;
|
||||
let valEnd = valStart;
|
||||
while (valEnd < limit && bytes[valEnd] !== QUOTE) {
|
||||
if (bytes[valEnd] === BACKSLASH) valEnd++;
|
||||
valEnd++;
|
||||
}
|
||||
// Decode just this slice
|
||||
try {
|
||||
task = new TextDecoder("utf-8", { fatal: true }).decode(
|
||||
bytes.slice(valStart, valEnd)
|
||||
);
|
||||
foundTask = true;
|
||||
} catch {
|
||||
// Leave as "unknown"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip past the key's closing quote
|
||||
i = keyEnd + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return { index, task };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an NDJSON parser transform stream.
|
||||
*
|
||||
* Converts a stream of Uint8Array chunks into parsed JSON objects.
|
||||
* Each line in the NDJSON is parsed independently.
|
||||
*
|
||||
* Uses byte-buffer accumulation to:
|
||||
* - Prevent OOM from unbounded string buffers
|
||||
* - Properly handle multibyte UTF-8 characters across chunk boundaries
|
||||
* - Check size limits on raw bytes before decoding
|
||||
*
|
||||
* @param maxItemBytes - Maximum allowed bytes per line (item)
|
||||
* @returns TransformStream that outputs parsed JSON objects
|
||||
*/
|
||||
export function createNdjsonParserStream(
|
||||
maxItemBytes: number
|
||||
): TransformStream<Uint8Array, unknown> {
|
||||
// Single decoder instance, reused for all lines
|
||||
const decoder = new TextDecoder("utf-8", { fatal: true });
|
||||
|
||||
// Byte buffer: array of chunks with tracked total length
|
||||
let chunks: Uint8Array[] = [];
|
||||
let totalBytes = 0;
|
||||
let lineNumber = 0;
|
||||
// 0-based position of the next object we emit (parsed item or oversized
|
||||
// marker). The parser is the single sequential point in the pipeline, so this
|
||||
// is the authoritative source of item ordering — downstream consumers can
|
||||
// process items concurrently and must not rely on processing order to derive
|
||||
// an item's index. Used to back-fill an oversized marker's index when it
|
||||
// couldn't be extracted from the (truncated) raw bytes.
|
||||
let emittedCount = 0;
|
||||
// When an oversized incomplete line is detected (Case 2), we must discard
|
||||
// all remaining bytes of that line until the next newline delimiter.
|
||||
let skipUntilNewline = false;
|
||||
|
||||
const NEWLINE_BYTE = 0x0a; // '\n'
|
||||
|
||||
/**
|
||||
* Emit a parsed object or marker downstream and advance the emit position.
|
||||
* Every emitted object MUST go through here so `emittedCount` stays aligned
|
||||
* with item position (empty/skipped lines never emit, so they don't count).
|
||||
*/
|
||||
function emit(controller: TransformStreamDefaultController<unknown>, obj: unknown): void {
|
||||
controller.enqueue(obj);
|
||||
emittedCount++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenate all chunks into a single Uint8Array
|
||||
*/
|
||||
function concatenateChunks(): Uint8Array {
|
||||
if (chunks.length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
if (chunks.length === 1) {
|
||||
return chunks[0];
|
||||
}
|
||||
const result = new Uint8Array(totalBytes);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the index of the first newline byte in the buffer.
|
||||
* Returns -1 if not found.
|
||||
*/
|
||||
function findNewlineIndex(): number {
|
||||
let globalIndex = 0;
|
||||
for (const chunk of chunks) {
|
||||
for (let i = 0; i < chunk.byteLength; i++) {
|
||||
if (chunk[i] === NEWLINE_BYTE) {
|
||||
return globalIndex + i;
|
||||
}
|
||||
}
|
||||
globalIndex += chunk.byteLength;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract bytes from the buffer up to (but not including) the given index,
|
||||
* and remove those bytes plus the delimiter from the buffer.
|
||||
*/
|
||||
function extractLine(newlineIndex: number): Uint8Array {
|
||||
const fullBuffer = concatenateChunks();
|
||||
const lineBytes = fullBuffer.slice(0, newlineIndex);
|
||||
const remaining = fullBuffer.slice(newlineIndex + 1); // Skip the newline
|
||||
|
||||
// Reset buffer with remaining bytes
|
||||
if (remaining.byteLength > 0) {
|
||||
chunks = [remaining];
|
||||
totalBytes = remaining.byteLength;
|
||||
} else {
|
||||
chunks = [];
|
||||
totalBytes = 0;
|
||||
}
|
||||
|
||||
return lineBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a line from bytes, handling whitespace trimming.
|
||||
* Returns the parsed object or null for empty lines.
|
||||
*/
|
||||
function parseLine(
|
||||
lineBytes: Uint8Array,
|
||||
controller: TransformStreamDefaultController<unknown>
|
||||
): void {
|
||||
lineNumber++;
|
||||
|
||||
// Decode the line bytes (stream: false since this is a complete line)
|
||||
let lineText: string;
|
||||
try {
|
||||
lineText = decoder.decode(lineBytes, { stream: false });
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid UTF-8 at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const trimmed = lineText.trim();
|
||||
if (!trimmed) {
|
||||
return; // Skip empty lines
|
||||
}
|
||||
|
||||
try {
|
||||
const obj = JSON.parse(trimmed);
|
||||
emit(controller, obj);
|
||||
} catch (err) {
|
||||
throw new Error(`Invalid JSON at line ${lineNumber}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return new TransformStream<Uint8Array, unknown>({
|
||||
transform(chunk, controller) {
|
||||
// If we're skipping the remainder of an oversized line, scan for the
|
||||
// next newline in this chunk and discard everything before it.
|
||||
if (skipUntilNewline) {
|
||||
const nlPos = chunk.indexOf(NEWLINE_BYTE);
|
||||
if (nlPos === -1) {
|
||||
// Entire chunk is still part of the oversized line — discard it
|
||||
return;
|
||||
}
|
||||
// Found the newline — keep everything after it
|
||||
skipUntilNewline = false;
|
||||
const remaining = chunk.slice(nlPos + 1);
|
||||
if (remaining.byteLength === 0) {
|
||||
return;
|
||||
}
|
||||
// Replace chunk with the remainder and fall through to normal processing
|
||||
chunk = remaining;
|
||||
}
|
||||
|
||||
// Append chunk to buffer
|
||||
chunks.push(chunk);
|
||||
totalBytes += chunk.byteLength;
|
||||
|
||||
// Process all complete lines in the buffer
|
||||
let newlineIndex: number;
|
||||
while ((newlineIndex = findNewlineIndex()) !== -1) {
|
||||
// Check size limit BEFORE extracting/decoding (bytes up to newline)
|
||||
if (newlineIndex > maxItemBytes) {
|
||||
// Case 1: Complete line exceeds limit - emit marker instead of throwing
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
const extracted = extractIndexAndTask(lineBytes);
|
||||
const marker: OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED",
|
||||
index: extracted.index >= 0 ? extracted.index : emittedCount,
|
||||
task: extracted.task,
|
||||
actualSize: newlineIndex,
|
||||
maxSize: maxItemBytes,
|
||||
};
|
||||
emit(controller, marker);
|
||||
lineNumber++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const lineBytes = extractLine(newlineIndex);
|
||||
parseLine(lineBytes, controller);
|
||||
}
|
||||
|
||||
// Check if the remaining buffer (incomplete line) exceeds the limit
|
||||
// This prevents OOM from a single huge line without newlines
|
||||
if (totalBytes > maxItemBytes) {
|
||||
// Case 2: Incomplete line exceeds limit - emit marker instead of throwing
|
||||
const extracted = extractIndexAndTask(concatenateChunks());
|
||||
const marker: OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED",
|
||||
index: extracted.index >= 0 ? extracted.index : emittedCount,
|
||||
task: extracted.task,
|
||||
actualSize: totalBytes,
|
||||
maxSize: maxItemBytes,
|
||||
};
|
||||
emit(controller, marker);
|
||||
lineNumber++;
|
||||
// Clear buffer and skip remaining bytes of this oversized line
|
||||
// until the next newline delimiter is found in a subsequent chunk
|
||||
chunks = [];
|
||||
totalBytes = 0;
|
||||
skipUntilNewline = true;
|
||||
return;
|
||||
}
|
||||
},
|
||||
|
||||
flush(controller) {
|
||||
// Flush any remaining bytes from the decoder's internal state
|
||||
// This handles multibyte characters that may have been split across chunks
|
||||
decoder.decode(new Uint8Array(0), { stream: false });
|
||||
|
||||
// Process any remaining buffered data (no trailing newline case)
|
||||
if (totalBytes === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check size limit before processing final line
|
||||
if (totalBytes > maxItemBytes) {
|
||||
// Case 3: Flush with oversized remaining - emit marker instead of throwing
|
||||
const extracted = extractIndexAndTask(concatenateChunks());
|
||||
const marker: OversizedItemMarker = {
|
||||
__batchItemError: "OVERSIZED",
|
||||
index: extracted.index >= 0 ? extracted.index : emittedCount,
|
||||
task: extracted.task,
|
||||
actualSize: totalBytes,
|
||||
maxSize: maxItemBytes,
|
||||
};
|
||||
emit(controller, marker);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBytes = concatenateChunks();
|
||||
parseLine(finalBytes, controller);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a ReadableStream into an AsyncIterable.
|
||||
* Useful for processing streams with for-await-of loops.
|
||||
*/
|
||||
export async function* streamToAsyncIterable<T>(stream: ReadableStream<T>): AsyncIterable<T> {
|
||||
const reader = stream.getReader();
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
yield value;
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Empty singletons satisfy the module-level wiring imports; the mint method under test is driven
|
||||
// directly via (service as any) and never touches the DB (same boundary as triggerTask.server.test.ts).
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
runOpsNewPrisma: {},
|
||||
runOpsLegacyPrisma: {},
|
||||
runOpsNewReplica: {},
|
||||
runOpsLegacyReplica: {},
|
||||
}));
|
||||
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
|
||||
|
||||
import { classifyKind, generateRunOpsId, RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { TriggerFailedTaskService } from "./triggerFailedTask.server";
|
||||
|
||||
function buildService() {
|
||||
return new TriggerFailedTaskService({ prisma: {} as any, engine: {} as any });
|
||||
}
|
||||
|
||||
describe("TriggerFailedTaskService.mintFailedRunFriendlyId", () => {
|
||||
it("returns the caller-supplied runFriendlyId verbatim (override wins over any mint)", async () => {
|
||||
const override = RunId.toFriendlyId(generateRunOpsId());
|
||||
const minted = await (buildService() as any).mintFailedRunFriendlyId({
|
||||
organizationId: "org_1",
|
||||
environmentId: "env_1",
|
||||
runFriendlyId: override,
|
||||
});
|
||||
expect(minted).toBe(override);
|
||||
});
|
||||
|
||||
it("without an override, still inherits a run-ops (NEW) parent by id-shape", async () => {
|
||||
const parentRunFriendlyId = RunId.toFriendlyId(generateRunOpsId());
|
||||
const minted = await (buildService() as any).mintFailedRunFriendlyId({
|
||||
organizationId: "org_1",
|
||||
environmentId: "env_1",
|
||||
parentRunFriendlyId,
|
||||
});
|
||||
expect(classifyKind(minted)).toBe("runOpsId");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,435 @@
|
||||
import type { RunEngine } from "@internal/run-engine";
|
||||
import { TaskRunErrorCodes, type TaskRunError } from "@trigger.dev/core/v3";
|
||||
import { RunId, generateRunOpsId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type {
|
||||
PrismaClientOrTransaction,
|
||||
RuntimeEnvironmentType,
|
||||
TaskRun,
|
||||
} from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
|
||||
import { resolveInheritedMintKind } from "~/v3/runOpsMigration/resolveInheritedMintKind.server";
|
||||
import { getEventRepository } from "~/v3/eventRepository/index.server";
|
||||
import { runStore as defaultRunStore } from "~/v3/runStore.server";
|
||||
import type { RunStore } from "@internal/run-store";
|
||||
import type { IEventRepository } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { PerformTaskRunAlertsService } from "~/v3/services/alerts/performTaskRunAlerts.server";
|
||||
import { DefaultQueueManager } from "../concerns/queues.server";
|
||||
import type { TriggerTaskRequest } from "../types";
|
||||
|
||||
export type TriggerFailedTaskRequest = {
|
||||
/** The task identifier (e.g. "my-task") */
|
||||
taskId: string;
|
||||
/** The fully-resolved authenticated environment */
|
||||
environment: AuthenticatedEnvironment;
|
||||
/** Raw payload — string or object */
|
||||
payload: unknown;
|
||||
/** MIME type of the payload (defaults to "application/json") */
|
||||
payloadType?: string;
|
||||
/** Error message describing why the run failed */
|
||||
errorMessage: string;
|
||||
/** Parent run friendly ID (e.g. "run_xxxx") */
|
||||
parentRunId?: string;
|
||||
/** Whether completing this run should resume the parent */
|
||||
resumeParentOnCompletion?: boolean;
|
||||
/** Batch association */
|
||||
batch?: { id: string; index: number };
|
||||
/** Trigger options from the original request (queue config, etc.) */
|
||||
options?: Record<string, unknown>;
|
||||
/** Trace context for span correlation */
|
||||
traceContext?: Record<string, unknown>;
|
||||
/** Whether the span parent should be treated as a link rather than a parent */
|
||||
spanParentAsLink?: boolean;
|
||||
|
||||
errorCode?: TaskRunErrorCodes;
|
||||
|
||||
/** Pre-minted friendlyId; when set it wins over the mint. Batch callers pass a batch-anchored id. */
|
||||
runFriendlyId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a pre-failed TaskRun with a trace event span.
|
||||
*
|
||||
* This is used when a task cannot be triggered (e.g. queue limit reached, validation
|
||||
* error, etc.) but we still need to record the failure so that:
|
||||
* - Batch completion can track the item
|
||||
* - Parent runs get unblocked
|
||||
* - The failed run shows up in the run logs view
|
||||
*
|
||||
* This service resolves the parent run (for rootTaskRunId/depth) and queue properties
|
||||
* the same way triggerTask does, so the run is correctly associated in the task tree
|
||||
* and the SpanPresenter can find the TaskQueue.
|
||||
*/
|
||||
export class TriggerFailedTaskService {
|
||||
private readonly prisma: PrismaClientOrTransaction;
|
||||
private readonly replicaPrisma: PrismaClientOrTransaction;
|
||||
private readonly engine: RunEngine;
|
||||
// Resolves the parent run for depth/root/parent linkage. Defaults to the shared
|
||||
// singleton (in production the same store the engine writes through). Injected in
|
||||
// tests so the read resolves on the same store the engine wrote to.
|
||||
private readonly runStore: RunStore;
|
||||
// Defaults to getEventRepository's org-flag resolution, which reads through the
|
||||
// global prisma client; tests inject a repository bound to their testcontainer DB.
|
||||
private readonly eventRepository?: { repository: IEventRepository; store: string };
|
||||
|
||||
constructor(opts: {
|
||||
prisma: PrismaClientOrTransaction;
|
||||
engine: RunEngine;
|
||||
replicaPrisma?: PrismaClientOrTransaction;
|
||||
runStore?: RunStore;
|
||||
eventRepository?: { repository: IEventRepository; store: string };
|
||||
}) {
|
||||
this.prisma = opts.prisma;
|
||||
this.replicaPrisma = opts.replicaPrisma ?? opts.prisma;
|
||||
this.engine = opts.engine;
|
||||
this.runStore = opts.runStore ?? defaultRunStore;
|
||||
this.eventRepository = opts.eventRepository;
|
||||
}
|
||||
|
||||
// Mint a failed run's friendlyId. The id-kind decides which store the run is
|
||||
// born in (cuid → legacy store, run-ops id → new store); the whole subgraph of a
|
||||
// run must agree. A caller-supplied runFriendlyId (batch-anchored id) wins verbatim;
|
||||
// otherwise root failed runs mint by the environment's setting and child failed runs
|
||||
// inherit the parent's current store so they never split.
|
||||
private async mintFailedRunFriendlyId(args: {
|
||||
organizationId: string;
|
||||
environmentId: string;
|
||||
orgFeatureFlags?: unknown;
|
||||
parentRunFriendlyId?: string;
|
||||
runFriendlyId?: string;
|
||||
}): Promise<string> {
|
||||
if (args.runFriendlyId) {
|
||||
return args.runFriendlyId;
|
||||
}
|
||||
|
||||
const mintKind = args.parentRunFriendlyId
|
||||
? resolveInheritedMintKind(args.parentRunFriendlyId)
|
||||
: await resolveRunIdMintKind({
|
||||
organizationId: args.organizationId,
|
||||
id: args.environmentId,
|
||||
orgFeatureFlags: args.orgFeatureFlags,
|
||||
});
|
||||
|
||||
return mintKind === "runOpsId"
|
||||
? RunId.toFriendlyId(generateRunOpsId())
|
||||
: RunId.generate().friendlyId;
|
||||
}
|
||||
|
||||
async call(request: TriggerFailedTaskRequest): Promise<string | null> {
|
||||
const taskRunError: TaskRunError = {
|
||||
type: "INTERNAL_ERROR" as const,
|
||||
code: request.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
|
||||
message: request.errorMessage,
|
||||
};
|
||||
|
||||
// Held for the catch's log line; the in-try `const` is what consumers use.
|
||||
let mintedFriendlyId: string | undefined;
|
||||
|
||||
try {
|
||||
// Mint inside the try: classifying a user-supplied parentRunId throws on
|
||||
// an unclassifiable id, so keep it within the catch's null-return contract.
|
||||
const failedRunFriendlyId = await this.mintFailedRunFriendlyId({
|
||||
organizationId: request.environment.organizationId,
|
||||
environmentId: request.environment.id,
|
||||
orgFeatureFlags: request.environment.organization.featureFlags,
|
||||
parentRunFriendlyId: request.parentRunId,
|
||||
runFriendlyId: request.runFriendlyId,
|
||||
});
|
||||
mintedFriendlyId = failedRunFriendlyId;
|
||||
|
||||
const { repository, store } =
|
||||
this.eventRepository ??
|
||||
(await getEventRepository(
|
||||
request.environment.organization.id,
|
||||
request.environment.organization.featureFlags as Record<string, unknown>,
|
||||
undefined
|
||||
));
|
||||
|
||||
// Resolve parent run for rootTaskRunId and depth (same as triggerTask.server.ts)
|
||||
const parentRun = request.parentRunId
|
||||
? await this.runStore.findRun(
|
||||
{
|
||||
id: RunId.fromFriendlyId(request.parentRunId),
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
},
|
||||
this.prisma
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const depth = parentRun ? parentRun.depth + 1 : 0;
|
||||
const rootTaskRunId = parentRun?.rootTaskRunId ?? parentRun?.id;
|
||||
|
||||
// Resolve queue properties (same as triggerTask) so span presenter can find TaskQueue.
|
||||
// Best-effort: if resolution throws (e.g. request shape, missing worker), we still create
|
||||
// the run without queue/lockedQueueId so run creation and trace events never regress.
|
||||
let queueName: string | undefined;
|
||||
let lockedQueueId: string | undefined;
|
||||
try {
|
||||
const queueConcern = new DefaultQueueManager(this.prisma, this.engine, this.replicaPrisma);
|
||||
const bodyOptions = request.options as TriggerTaskRequest["body"]["options"];
|
||||
const triggerRequest: TriggerTaskRequest = {
|
||||
taskId: request.taskId,
|
||||
friendlyId: failedRunFriendlyId,
|
||||
environment: request.environment,
|
||||
body: {
|
||||
payload:
|
||||
typeof request.payload === "string"
|
||||
? request.payload
|
||||
: JSON.stringify(request.payload ?? {}),
|
||||
options: bodyOptions,
|
||||
},
|
||||
};
|
||||
|
||||
// Resolve the locked background worker if lockToVersion is set (same as triggerTask).
|
||||
// resolveQueueProperties requires the worker to be passed when lockToVersion is present.
|
||||
const lockedToBackgroundWorker = bodyOptions?.lockToVersion
|
||||
? await this.prisma.backgroundWorker.findFirst({
|
||||
where: {
|
||||
projectId: request.environment.projectId,
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
version: bodyOptions.lockToVersion,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const resolved = await queueConcern.resolveQueueProperties(
|
||||
triggerRequest,
|
||||
lockedToBackgroundWorker ?? undefined
|
||||
);
|
||||
queueName = resolved.queueName;
|
||||
lockedQueueId = resolved.lockedQueueId;
|
||||
} catch (queueResolveError) {
|
||||
const err =
|
||||
queueResolveError instanceof Error
|
||||
? queueResolveError
|
||||
: new Error(String(queueResolveError));
|
||||
logger.warn("TriggerFailedTaskService: queue resolution failed, using defaults", {
|
||||
taskId: request.taskId,
|
||||
friendlyId: failedRunFriendlyId,
|
||||
error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Create the failed run inside a trace event span so it shows up in run logs
|
||||
const failedRun: TaskRun = await repository.traceEvent(
|
||||
request.taskId,
|
||||
{
|
||||
context: request.traceContext,
|
||||
spanParentAsLink: request.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: {
|
||||
id: request.environment.id,
|
||||
type: request.environment.type,
|
||||
organizationId: request.environment.organizationId,
|
||||
projectId: request.environment.projectId,
|
||||
project: { externalRef: request.environment.project.externalRef },
|
||||
},
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {},
|
||||
style: { icon: "task" },
|
||||
},
|
||||
incomplete: false,
|
||||
isError: true,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext) => {
|
||||
event.setAttribute("runId", failedRunFriendlyId);
|
||||
event.failWithError(taskRunError);
|
||||
|
||||
// `emitRunFailedEvent: false` because this call site owns the
|
||||
// trace-event lifecycle via the outer `traceEvent({
|
||||
// incomplete: false, isError: true })`. Letting the engine
|
||||
// emit `runFailed` here would race the
|
||||
// `completeFailedRunEvent` listener against the outer trace
|
||||
// event's own completion write for the same (traceId, spanId).
|
||||
// We re-trigger the alerts side directly after the trace
|
||||
// event closes, below.
|
||||
return await this.engine.createFailedTaskRun({
|
||||
friendlyId: failedRunFriendlyId,
|
||||
environment: {
|
||||
id: request.environment.id,
|
||||
type: request.environment.type,
|
||||
project: { id: request.environment.project.id },
|
||||
organization: { id: request.environment.organization.id },
|
||||
},
|
||||
taskIdentifier: request.taskId,
|
||||
payload:
|
||||
typeof request.payload === "string"
|
||||
? request.payload
|
||||
: JSON.stringify(request.payload ?? ""),
|
||||
payloadType: request.payloadType ?? "application/json",
|
||||
error: taskRunError,
|
||||
parentTaskRunId: parentRun?.id,
|
||||
rootTaskRunId,
|
||||
depth,
|
||||
resumeParentOnCompletion: request.resumeParentOnCompletion,
|
||||
batch: request.batch,
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext: traceContext as Record<string, unknown>,
|
||||
taskEventStore: store,
|
||||
emitRunFailedEvent: false,
|
||||
...(queueName !== undefined && { queue: queueName }),
|
||||
...(lockedQueueId !== undefined && { lockedQueueId }),
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// Alerts side of `runFailed` — the engine emit was suppressed
|
||||
// above so the trace-event completion isn't double-written; we
|
||||
// still need the alert pipeline to fire so customers' ERROR
|
||||
// channels see the failure. Best-effort: a failed enqueue logs
|
||||
// but doesn't block returning the friendlyId, mirroring the
|
||||
// engine handler's behaviour at runEngineHandlers.server.ts:81.
|
||||
try {
|
||||
await PerformTaskRunAlertsService.enqueue(failedRun.id);
|
||||
} catch (alertsError) {
|
||||
logger.warn("TriggerFailedTaskService: alert enqueue failed", {
|
||||
taskId: request.taskId,
|
||||
friendlyId: failedRun.friendlyId,
|
||||
error: alertsError instanceof Error ? alertsError.message : String(alertsError),
|
||||
});
|
||||
}
|
||||
|
||||
return failedRun.friendlyId;
|
||||
} catch (createError) {
|
||||
const createErrorMsg =
|
||||
createError instanceof Error ? createError.message : String(createError);
|
||||
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun", {
|
||||
taskId: request.taskId,
|
||||
friendlyId: mintedFriendlyId,
|
||||
originalError: request.errorMessage,
|
||||
createError: createErrorMsg,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a pre-failed run without trace events.
|
||||
* Used when the environment can't be fully resolved (e.g. environment not found)
|
||||
* and we can't create trace events or look up parent runs.
|
||||
*/
|
||||
async callWithoutTraceEvents(opts: {
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
projectId: string;
|
||||
organizationId: string;
|
||||
taskId: string;
|
||||
payload: unknown;
|
||||
payloadType?: string;
|
||||
errorMessage: string;
|
||||
parentRunId?: string;
|
||||
resumeParentOnCompletion?: boolean;
|
||||
batch?: { id: string; index: number };
|
||||
errorCode?: TaskRunErrorCodes;
|
||||
/** Pre-minted friendlyId; when set it wins over the mint. Batch callers pass a batch-anchored id. */
|
||||
runFriendlyId?: string;
|
||||
}): Promise<string | null> {
|
||||
// Held for the catch's log line; the in-try `const` is what consumers use.
|
||||
let mintedFriendlyId: string | undefined;
|
||||
|
||||
try {
|
||||
// Mint inside the try: classifying a user-supplied parentRunId throws on
|
||||
// an unclassifiable id, so keep it within the catch's null-return contract.
|
||||
const failedRunFriendlyId = await this.mintFailedRunFriendlyId({
|
||||
organizationId: opts.organizationId,
|
||||
environmentId: opts.environmentId,
|
||||
// No loaded org flags in this path; resolveRunIdMintKind falls back to a
|
||||
// single replica lookup by organizationId only when there is no parent.
|
||||
orgFeatureFlags: undefined,
|
||||
parentRunFriendlyId: opts.parentRunId,
|
||||
runFriendlyId: opts.runFriendlyId,
|
||||
});
|
||||
mintedFriendlyId = failedRunFriendlyId;
|
||||
|
||||
// Best-effort parent run lookup for rootTaskRunId/depth
|
||||
let parentTaskRunId: string | undefined;
|
||||
let rootTaskRunId: string | undefined;
|
||||
let depth = 0;
|
||||
|
||||
if (opts.parentRunId) {
|
||||
const parentRun = await this.runStore.findRun(
|
||||
{
|
||||
id: RunId.fromFriendlyId(opts.parentRunId),
|
||||
runtimeEnvironmentId: opts.environmentId,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
if (parentRun) {
|
||||
parentTaskRunId = parentRun.id;
|
||||
rootTaskRunId = parentRun.rootTaskRunId ?? parentRun.id;
|
||||
depth = parentRun.depth + 1;
|
||||
} else {
|
||||
parentTaskRunId = RunId.fromFriendlyId(opts.parentRunId);
|
||||
}
|
||||
}
|
||||
|
||||
const failedRun = await this.engine.createFailedTaskRun({
|
||||
friendlyId: failedRunFriendlyId,
|
||||
environment: {
|
||||
id: opts.environmentId,
|
||||
type: opts.environmentType,
|
||||
project: { id: opts.projectId },
|
||||
organization: { id: opts.organizationId },
|
||||
},
|
||||
taskIdentifier: opts.taskId,
|
||||
payload:
|
||||
typeof opts.payload === "string" ? opts.payload : JSON.stringify(opts.payload ?? ""),
|
||||
payloadType: opts.payloadType ?? "application/json",
|
||||
error: {
|
||||
type: "INTERNAL_ERROR" as const,
|
||||
code: opts.errorCode ?? TaskRunErrorCodes.UNSPECIFIED_ERROR,
|
||||
message: opts.errorMessage,
|
||||
},
|
||||
parentTaskRunId,
|
||||
rootTaskRunId,
|
||||
depth,
|
||||
resumeParentOnCompletion: opts.resumeParentOnCompletion,
|
||||
batch: opts.batch,
|
||||
// Suppress the engine's `runFailed` bus emit — the listener
|
||||
// (`runEngineHandlers.server.ts` `runFailed`) calls
|
||||
// `completeFailedRunEvent`, which writes a ClickHouse trace event
|
||||
// row keyed on (traceId, spanId). This caller has no trace
|
||||
// context (the method name is literally `callWithoutTraceEvents`)
|
||||
// so the emit would write a row with empty traceId/spanId —
|
||||
// orphan event in the store. We still want alert coverage,
|
||||
// though, so enqueue directly below.
|
||||
emitRunFailedEvent: false,
|
||||
});
|
||||
|
||||
// Alerts side of `runFailed` — the engine emit was suppressed
|
||||
// above so we don't create an orphan trace event; enqueue the
|
||||
// alert directly so customers' ERROR channels still see the
|
||||
// failure. Best-effort, mirroring the `call()` path.
|
||||
try {
|
||||
await PerformTaskRunAlertsService.enqueue(failedRun.id);
|
||||
} catch (alertsError) {
|
||||
logger.warn("TriggerFailedTaskService.callWithoutTraceEvents: alert enqueue failed", {
|
||||
taskId: opts.taskId,
|
||||
friendlyId: failedRun.friendlyId,
|
||||
error: alertsError instanceof Error ? alertsError.message : String(alertsError),
|
||||
});
|
||||
}
|
||||
|
||||
return failedRunFriendlyId;
|
||||
} catch (createError) {
|
||||
logger.error("TriggerFailedTaskService: failed to create pre-failed TaskRun (no trace)", {
|
||||
taskId: opts.taskId,
|
||||
friendlyId: mintedFriendlyId,
|
||||
originalError: opts.errorMessage,
|
||||
createError: createError instanceof Error ? createError.message : String(createError),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,825 @@
|
||||
import { describe, expect, vi } from "vitest";
|
||||
|
||||
// Mock the db prisma client. The service is constructed against a real
|
||||
// testcontainer prisma instead — these empty singletons only satisfy the
|
||||
// module-level imports of the production wiring (infrastructure boundary).
|
||||
vi.mock("~/db.server", () => ({
|
||||
prisma: {},
|
||||
$replica: {},
|
||||
runOpsNewPrisma: {},
|
||||
runOpsLegacyPrisma: {},
|
||||
runOpsNewReplica: {},
|
||||
runOpsLegacyReplica: {},
|
||||
}));
|
||||
// Inherited harness boilerplate. The parent read under test takes the
|
||||
// findRun(where, client) overload with this.prisma, so it does not consult this
|
||||
// flag; the mock only satisfies other wiring imported transitively.
|
||||
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false }));
|
||||
|
||||
vi.mock("~/services/platform.v3.server", async (importOriginal) => {
|
||||
const actual = (await importOriginal()) as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
getEntitlement: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { RunEngine } from "@internal/run-engine";
|
||||
import { setupAuthenticatedEnvironment, setupBackgroundWorker } from "@internal/run-engine/tests";
|
||||
import { assertNonNullable, containerTest } from "@internal/testcontainers";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import type { IOPacket } from "@trigger.dev/core/v3";
|
||||
import type { TaskRun } from "@trigger.dev/database";
|
||||
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
|
||||
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
|
||||
import type {
|
||||
EntitlementValidationParams,
|
||||
MaxAttemptsValidationParams,
|
||||
ParentRunValidationParams,
|
||||
PayloadProcessor,
|
||||
TagValidationParams,
|
||||
TracedEventSpan,
|
||||
TraceEventConcern,
|
||||
TriggerTaskRequest,
|
||||
TriggerTaskValidator,
|
||||
ValidationResult,
|
||||
} from "~/runEngine/types";
|
||||
import { RunEngineTriggerTaskService } from "./triggerTask.server";
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 }); // 60 seconds timeout
|
||||
|
||||
class MockPayloadProcessor implements PayloadProcessor {
|
||||
async process(request: TriggerTaskRequest): Promise<IOPacket> {
|
||||
return {
|
||||
data: JSON.stringify(request.body.payload),
|
||||
dataType: "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Captures the `parentRun` the service resolved (via runStore.findRun) and
|
||||
// passed into validation, so a test can assert on the resolved parent without
|
||||
// mocking the read itself. Returns ok so the child triggers regardless.
|
||||
class CapturingParentRunValidator implements TriggerTaskValidator {
|
||||
public capturedParentRun: ParentRunValidationParams["parentRun"] | "unset" = "unset";
|
||||
|
||||
validateTags(_params: TagValidationParams): ValidationResult {
|
||||
return { ok: true };
|
||||
}
|
||||
validateEntitlement(_params: EntitlementValidationParams): Promise<ValidationResult> {
|
||||
return Promise.resolve({ ok: true });
|
||||
}
|
||||
validateMaxAttempts(_params: MaxAttemptsValidationParams): ValidationResult {
|
||||
return { ok: true };
|
||||
}
|
||||
validateParentRun(params: ParentRunValidationParams): ValidationResult {
|
||||
this.capturedParentRun = params.parentRun;
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
|
||||
class MockTraceEventConcern implements TraceEventConcern {
|
||||
async traceRun<T>(
|
||||
_request: TriggerTaskRequest,
|
||||
_parentStore: string | undefined,
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
return await callback(
|
||||
{
|
||||
traceId: "test",
|
||||
spanId: "test",
|
||||
traceContext: {},
|
||||
traceparent: undefined,
|
||||
setAttribute: () => {},
|
||||
failWithError: () => {},
|
||||
stop: () => {},
|
||||
},
|
||||
"test"
|
||||
);
|
||||
}
|
||||
|
||||
async traceIdempotentRun<T>(
|
||||
_request: TriggerTaskRequest,
|
||||
_parentStore: string | undefined,
|
||||
_options: {
|
||||
existingRun: TaskRun;
|
||||
idempotencyKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
return await callback(
|
||||
{
|
||||
traceId: "test",
|
||||
spanId: "test",
|
||||
traceContext: {},
|
||||
traceparent: undefined,
|
||||
setAttribute: () => {},
|
||||
failWithError: () => {},
|
||||
stop: () => {},
|
||||
},
|
||||
"test"
|
||||
);
|
||||
}
|
||||
|
||||
async traceDebouncedRun<T>(
|
||||
_request: TriggerTaskRequest,
|
||||
_parentStore: string | undefined,
|
||||
_options: {
|
||||
existingRun: TaskRun;
|
||||
debounceKey: string;
|
||||
incomplete: boolean;
|
||||
isError: boolean;
|
||||
},
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
return await callback(
|
||||
{
|
||||
traceId: "test",
|
||||
spanId: "test",
|
||||
traceContext: {},
|
||||
traceparent: undefined,
|
||||
setAttribute: () => {},
|
||||
failWithError: () => {},
|
||||
stop: () => {},
|
||||
},
|
||||
"test"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function buildEngine(prisma: any, redisOptions: any) {
|
||||
return new RunEngine({
|
||||
prisma,
|
||||
worker: {
|
||||
redis: redisOptions,
|
||||
workers: 1,
|
||||
tasksPerWorker: 10,
|
||||
pollIntervalMs: 100,
|
||||
},
|
||||
queue: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
runLock: {
|
||||
redis: redisOptions,
|
||||
},
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": {
|
||||
name: "small-1x" as const,
|
||||
cpu: 0.5,
|
||||
memory: 0.5,
|
||||
centsPerMs: 0.0001,
|
||||
},
|
||||
},
|
||||
baseCostInCents: 0.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
}
|
||||
|
||||
describe("RunEngineTriggerTaskService parent + locked-worker reads", () => {
|
||||
containerTest(
|
||||
"resolves the parent run through the run-ops store by minted run id",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const validator = new CapturingParentRunValidator();
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator,
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// Trigger a ROOT run first to create a real parent TaskRun.
|
||||
const parentResult = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: { payload: { kind: "parent" } },
|
||||
});
|
||||
assertNonNullable(parentResult);
|
||||
|
||||
// Trigger a CHILD pointing at the parent's friendlyId. The service must
|
||||
// resolve the parent via runStore.findRun (minted RunId, env-scoped).
|
||||
const childResult = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: {
|
||||
payload: { kind: "child" },
|
||||
options: { parentRunId: parentResult.run.friendlyId },
|
||||
},
|
||||
});
|
||||
assertNonNullable(childResult);
|
||||
|
||||
// The capturing validator observed the resolved parent — proving the
|
||||
// read ran (against the container DB) and returned the right row.
|
||||
expect(validator.capturedParentRun).not.toBe("unset");
|
||||
const capturedParent = validator.capturedParentRun;
|
||||
assertNonNullable(capturedParent);
|
||||
expect(capturedParent.id).toBe(parentResult.run.id);
|
||||
expect(capturedParent.friendlyId).toBe(parentResult.run.friendlyId);
|
||||
|
||||
// depth and root carry through — proving parentRun.depth and the parent
|
||||
// id were read off the resolved row and threaded into the child.
|
||||
const parentRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: parentResult.run.id },
|
||||
});
|
||||
const childRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: childResult.run.id },
|
||||
});
|
||||
|
||||
expect(childRow.depth).toBe(parentRow.depth + 1);
|
||||
expect(childRow.parentTaskRunId).toBe(parentRow.id);
|
||||
expect(childRow.rootTaskRunId).toBe(parentRow.id);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"scopes the parent lookup to the run's environment (cross-env parent is not resolved)",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
// Two independent authenticated environments. The setup helper hardcodes
|
||||
// several globally-unique fields (org/project slug, env apiKey/pkApiKey,
|
||||
// worker-group token hash), so rename envA's before the second call to
|
||||
// avoid unique-constraint collisions.
|
||||
const envA = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
await prisma.organization.update({
|
||||
where: { id: envA.organizationId },
|
||||
data: { slug: `${envA.organization.slug}-a` },
|
||||
});
|
||||
await prisma.project.update({
|
||||
where: { id: envA.projectId },
|
||||
data: { slug: `${envA.project.slug}-a`, externalRef: `${envA.project.externalRef}-a` },
|
||||
});
|
||||
await prisma.runtimeEnvironment.update({
|
||||
where: { id: envA.id },
|
||||
data: { apiKey: `${envA.apiKey}-a`, pkApiKey: `${envA.pkApiKey}-a` },
|
||||
});
|
||||
await prisma.workerGroupToken.updateMany({
|
||||
where: { tokenHash: "token_hash" },
|
||||
data: { tokenHash: "token_hash_a" },
|
||||
});
|
||||
await prisma.workerInstanceGroup.updateMany({
|
||||
where: { masterQueue: "default" },
|
||||
data: { masterQueue: "default_a" },
|
||||
});
|
||||
const envB = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
expect(envA.id).not.toBe(envB.id);
|
||||
expect(envA.organizationId).not.toBe(envB.organizationId);
|
||||
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, envA, taskIdentifier);
|
||||
await setupBackgroundWorker(engine, envB, taskIdentifier);
|
||||
|
||||
const validator = new CapturingParentRunValidator();
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator,
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// A real parent run in envA.
|
||||
const parentResult = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: envA,
|
||||
body: { payload: { kind: "parent" } },
|
||||
});
|
||||
assertNonNullable(parentResult);
|
||||
|
||||
// Trigger a child in envB pointing at the envA parent's friendlyId. The
|
||||
// env guard in runStore.findRun's `where` rejects the cross-env parent
|
||||
// in a single query, so the resolved parentRun is null.
|
||||
const childResult = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: envB,
|
||||
body: {
|
||||
payload: { kind: "child" },
|
||||
options: { parentRunId: parentResult.run.friendlyId },
|
||||
},
|
||||
});
|
||||
assertNonNullable(childResult);
|
||||
|
||||
// validateParentRun was called with no resolved parent.
|
||||
expect(validator.capturedParentRun).not.toBe("unset");
|
||||
expect(validator.capturedParentRun ?? null).toBeNull();
|
||||
|
||||
// The child still triggered, at the root depth with no parent linkage —
|
||||
// confirming the cross-env parent was dropped, not silently joined.
|
||||
const childRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: childResult.run.id },
|
||||
});
|
||||
expect(childRow.depth).toBe(0);
|
||||
expect(childRow.parentTaskRunId).toBeNull();
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"resolves the locked background worker on the control-plane client with no cross-DB join",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
const { worker } = await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
// Read the seeded worker row to get its real version/id.
|
||||
const workerRow = await prisma.backgroundWorker.findUniqueOrThrow({
|
||||
where: { id: worker.id },
|
||||
});
|
||||
|
||||
// Counting proxy over the control-plane client. `this.prisma` is ALWAYS
|
||||
// the control-plane client; the locked-worker lookup is a DIRECT
|
||||
// backgroundWorker.findFirst on it. The parent read uses a DIFFERENT
|
||||
// call (runStore.findRun → taskRun), so a single call() issues two
|
||||
// separate single-table reads — never one cross-seam join. Here we count
|
||||
// the findFirst calls and capture their args to assert no include/join.
|
||||
let backgroundWorkerFindFirstCalls = 0;
|
||||
const findFirstArgs: any[] = [];
|
||||
const countingPrisma = new Proxy(prisma, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "backgroundWorker") {
|
||||
const delegate = Reflect.get(target, prop, receiver);
|
||||
return new Proxy(delegate, {
|
||||
get(bwTarget, bwProp, bwReceiver) {
|
||||
if (bwProp === "findFirst") {
|
||||
return async (args: any) => {
|
||||
backgroundWorkerFindFirstCalls += 1;
|
||||
findFirstArgs.push(args);
|
||||
return (delegate as any).findFirst(args);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(bwTarget, bwProp, bwReceiver);
|
||||
return typeof value === "function" ? value.bind(bwTarget) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as typeof prisma;
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma: countingPrisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
// The queue manager gets the real (unproxied) prisma so the counting
|
||||
// proxy only observes reads issued by the service itself.
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator: new CapturingParentRunValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: {
|
||||
payload: { kind: "locked" },
|
||||
options: { lockToVersion: workerRow.version },
|
||||
},
|
||||
});
|
||||
assertNonNullable(result);
|
||||
|
||||
// Observable proof the locked worker was resolved on the control-plane
|
||||
// client: the created run records the worker id in lockedToVersionId.
|
||||
const runRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: result.run.id },
|
||||
});
|
||||
expect(runRow.lockedToVersionId).toBe(workerRow.id);
|
||||
expect(runRow.taskVersion).toBe(workerRow.version);
|
||||
|
||||
// Exactly one backgroundWorker.findFirst fired for the locked-worker read.
|
||||
expect(backgroundWorkerFindFirstCalls).toBe(1);
|
||||
|
||||
// NO-JOIN assertion: the read referenced ONLY the backgroundWorker table.
|
||||
// No `include` (which would join into another table); the `select` lists
|
||||
// only backgroundWorker scalar columns.
|
||||
const args = findFirstArgs[0];
|
||||
assertNonNullable(args);
|
||||
expect(args.include).toBeUndefined();
|
||||
expect(Object.keys(args.select ?? {}).sort()).toEqual([
|
||||
"cliVersion",
|
||||
"id",
|
||||
"sdkVersion",
|
||||
"version",
|
||||
]);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"issues two independent single-table reads when one call supplies both parentRunId and lockToVersion",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
const { worker } = await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const workerRow = await prisma.backgroundWorker.findUniqueOrThrow({
|
||||
where: { id: worker.id },
|
||||
});
|
||||
|
||||
// Count BOTH reads issued by the service on the control-plane client:
|
||||
// the parent read (runStore.findRun → taskRun.findFirst) and the
|
||||
// locked-worker read (backgroundWorker.findFirst). Capture every
|
||||
// findFirst arg so we can assert no read carries a cross-seam include.
|
||||
let taskRunFindFirstCalls = 0;
|
||||
let backgroundWorkerFindFirstCalls = 0;
|
||||
const findFirstArgs: any[] = [];
|
||||
const countingPrisma = new Proxy(prisma, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "backgroundWorker") {
|
||||
const delegate = Reflect.get(target, prop, receiver);
|
||||
return new Proxy(delegate, {
|
||||
get(bwTarget, bwProp, bwReceiver) {
|
||||
if (bwProp === "findFirst") {
|
||||
return async (args: any) => {
|
||||
backgroundWorkerFindFirstCalls += 1;
|
||||
findFirstArgs.push(args);
|
||||
return (delegate as any).findFirst(args);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(bwTarget, bwProp, bwReceiver);
|
||||
return typeof value === "function" ? value.bind(bwTarget) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
if (prop === "taskRun") {
|
||||
const delegate = Reflect.get(target, prop, receiver);
|
||||
return new Proxy(delegate, {
|
||||
get(trTarget, trProp, trReceiver) {
|
||||
if (trProp === "findFirst") {
|
||||
return async (args: any) => {
|
||||
taskRunFindFirstCalls += 1;
|
||||
findFirstArgs.push(args);
|
||||
return (delegate as any).findFirst(args);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(trTarget, trProp, trReceiver);
|
||||
return typeof value === "function" ? value.bind(trTarget) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as typeof prisma;
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma: countingPrisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
// queueConcern/idempotency get the real unproxied prisma so the
|
||||
// counting proxy only observes reads issued by the service itself.
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator: new CapturingParentRunValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// ROOT parent first (uses the unproxied prisma via a separate service so
|
||||
// its internal reads don't pollute the child's counts).
|
||||
const parentService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator: new CapturingParentRunValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
const parentResult = await parentService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: { payload: { kind: "parent" } },
|
||||
});
|
||||
assertNonNullable(parentResult);
|
||||
|
||||
// CHILD supplying BOTH parentRunId AND lockToVersion in one call.
|
||||
const childResult = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: {
|
||||
payload: { kind: "child" },
|
||||
options: {
|
||||
parentRunId: parentResult.run.friendlyId,
|
||||
lockToVersion: workerRow.version,
|
||||
},
|
||||
},
|
||||
});
|
||||
assertNonNullable(childResult);
|
||||
|
||||
const parentRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: parentResult.run.id },
|
||||
});
|
||||
const childRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: childResult.run.id },
|
||||
});
|
||||
|
||||
// Child resolved the parent (single-table parent read).
|
||||
expect(childRow.parentTaskRunId).toBe(parentRow.id);
|
||||
expect(childRow.depth).toBe(parentRow.depth + 1);
|
||||
|
||||
// Child locked to the worker (single-table worker read).
|
||||
expect(childRow.lockedToVersionId).toBe(workerRow.id);
|
||||
expect(childRow.taskVersion).toBe(workerRow.version);
|
||||
|
||||
// Exactly one backgroundWorker.findFirst fired for the locked-worker read,
|
||||
// and at least one taskRun.findFirst fired for the parent read.
|
||||
expect(backgroundWorkerFindFirstCalls).toBe(1);
|
||||
expect(taskRunFindFirstCalls).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// NO-JOIN proof: no captured read carried an `include` joining
|
||||
// taskRun <-> backgroundWorker. Every findFirst arg has include undefined.
|
||||
for (const args of findFirstArgs) {
|
||||
expect(args?.include).toBeUndefined();
|
||||
}
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"lockToVersion matching no worker rejects the trigger after a single scalar-only worker read",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
let backgroundWorkerFindFirstCalls = 0;
|
||||
const findFirstArgs: any[] = [];
|
||||
const countingPrisma = new Proxy(prisma, {
|
||||
get(target, prop, receiver) {
|
||||
if (prop === "backgroundWorker") {
|
||||
const delegate = Reflect.get(target, prop, receiver);
|
||||
return new Proxy(delegate, {
|
||||
get(bwTarget, bwProp, bwReceiver) {
|
||||
if (bwProp === "findFirst") {
|
||||
return async (args: any) => {
|
||||
backgroundWorkerFindFirstCalls += 1;
|
||||
findFirstArgs.push(args);
|
||||
return (delegate as any).findFirst(args);
|
||||
};
|
||||
}
|
||||
const value = Reflect.get(bwTarget, bwProp, bwReceiver);
|
||||
return typeof value === "function" ? value.bind(bwTarget) : value;
|
||||
},
|
||||
});
|
||||
}
|
||||
const value = Reflect.get(target, prop, receiver);
|
||||
return typeof value === "function" ? value.bind(target) : value;
|
||||
},
|
||||
}) as typeof prisma;
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma: countingPrisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator: new CapturingParentRunValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
const bogusVersion = "v-does-not-exist-0000";
|
||||
// The no-match worker read returns null; the queue concern then rejects
|
||||
// the trigger rather than silently locking the run to a phantom version.
|
||||
await expect(
|
||||
triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: {
|
||||
payload: { kind: "locked" },
|
||||
options: { lockToVersion: bogusVersion },
|
||||
},
|
||||
})
|
||||
).rejects.toThrow(/no worker found with that version/);
|
||||
|
||||
// No run was locked to the bogus version (none was created).
|
||||
const lockedRuns = await prisma.taskRun.findMany({
|
||||
where: { runtimeEnvironmentId: environment.id, taskVersion: bogusVersion },
|
||||
});
|
||||
expect(lockedRuns).toEqual([]);
|
||||
|
||||
// The lone worker read fired exactly once with the scalar-only select and
|
||||
// no cross-seam include.
|
||||
expect(backgroundWorkerFindFirstCalls).toBe(1);
|
||||
const args = findFirstArgs[0];
|
||||
assertNonNullable(args);
|
||||
expect(args.include).toBeUndefined();
|
||||
expect(Object.keys(args.select ?? {}).sort()).toEqual([
|
||||
"cliVersion",
|
||||
"id",
|
||||
"sdkVersion",
|
||||
"version",
|
||||
]);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"does not resolve a locked worker from a different environment",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
// Two independent authenticated environments. Rename envA's globally-unique
|
||||
// fields before the second setup call to avoid unique-constraint collisions.
|
||||
const envA = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
await prisma.organization.update({
|
||||
where: { id: envA.organizationId },
|
||||
data: { slug: `${envA.organization.slug}-a` },
|
||||
});
|
||||
await prisma.project.update({
|
||||
where: { id: envA.projectId },
|
||||
data: { slug: `${envA.project.slug}-a`, externalRef: `${envA.project.externalRef}-a` },
|
||||
});
|
||||
await prisma.runtimeEnvironment.update({
|
||||
where: { id: envA.id },
|
||||
data: { apiKey: `${envA.apiKey}-a`, pkApiKey: `${envA.pkApiKey}-a` },
|
||||
});
|
||||
await prisma.workerGroupToken.updateMany({
|
||||
where: { tokenHash: "token_hash" },
|
||||
data: { tokenHash: "token_hash_a" },
|
||||
});
|
||||
await prisma.workerInstanceGroup.updateMany({
|
||||
where: { masterQueue: "default" },
|
||||
data: { masterQueue: "default_a" },
|
||||
});
|
||||
const envB = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
expect(envA.id).not.toBe(envB.id);
|
||||
expect(envA.organizationId).not.toBe(envB.organizationId);
|
||||
|
||||
const taskIdentifier = "test-task";
|
||||
const { worker: workerA } = await setupBackgroundWorker(engine, envA, taskIdentifier);
|
||||
const { worker: workerB } = await setupBackgroundWorker(engine, envB, taskIdentifier);
|
||||
|
||||
const workerARow = await prisma.backgroundWorker.findUniqueOrThrow({
|
||||
where: { id: workerA.id },
|
||||
});
|
||||
const workerBRow = await prisma.backgroundWorker.findUniqueOrThrow({
|
||||
where: { id: workerB.id },
|
||||
});
|
||||
// Both seeded workers share the same version string.
|
||||
expect(workerARow.version).toBe(workerBRow.version);
|
||||
expect(workerARow.id).not.toBe(workerBRow.id);
|
||||
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator: new CapturingParentRunValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// Trigger in envB locking to the shared version string.
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment: envB,
|
||||
body: {
|
||||
payload: { kind: "locked" },
|
||||
options: { lockToVersion: workerBRow.version },
|
||||
},
|
||||
});
|
||||
assertNonNullable(result);
|
||||
|
||||
const runRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: result.run.id },
|
||||
});
|
||||
// The projectId + runtimeEnvironmentId guard in the single-table worker
|
||||
// read resolves envB's worker, never envA's same-version worker.
|
||||
expect(runRow.lockedToVersionId).toBe(workerBRow.id);
|
||||
expect(runRow.lockedToVersionId).not.toBe(workerARow.id);
|
||||
expect(runRow.taskVersion).toBe(workerBRow.version);
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
containerTest("a root trigger issues no parent lookup", async ({ prisma, redisOptions }) => {
|
||||
const engine = buildEngine(prisma, redisOptions);
|
||||
|
||||
try {
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "test-task";
|
||||
await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const validator = new CapturingParentRunValidator();
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: new DefaultQueueManager(prisma, engine),
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(
|
||||
prisma,
|
||||
engine,
|
||||
new MockTraceEventConcern()
|
||||
),
|
||||
validator,
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024 * 1,
|
||||
});
|
||||
|
||||
// Trigger with NO parentRunId.
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: { payload: { kind: "root" } },
|
||||
});
|
||||
assertNonNullable(result);
|
||||
|
||||
// The validator ran but received no resolved parent: the parent read was
|
||||
// skipped because no parentRunId was supplied.
|
||||
expect(validator.capturedParentRun).not.toBe("unset");
|
||||
expect(validator.capturedParentRun).toBeUndefined();
|
||||
|
||||
const runRow = await prisma.taskRun.findUniqueOrThrow({
|
||||
where: { id: result.run.id },
|
||||
});
|
||||
expect(runRow.depth).toBe(0);
|
||||
expect(runRow.parentTaskRunId).toBeNull();
|
||||
} finally {
|
||||
await engine.quit();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,942 @@
|
||||
import {
|
||||
type RunEngine,
|
||||
RunDuplicateIdempotencyKeyError,
|
||||
RunOneTimeUseTokenError,
|
||||
} from "@internal/run-engine";
|
||||
import type { Tracer } from "@opentelemetry/api";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import {
|
||||
type TriggerTaskRequestBody,
|
||||
RunAnnotations,
|
||||
TaskRunError,
|
||||
taskRunErrorEnhancer,
|
||||
taskRunErrorToString,
|
||||
TriggerTraceContext,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import {
|
||||
parseTraceparent,
|
||||
RunId,
|
||||
serializeTraceparent,
|
||||
stringifyDuration,
|
||||
} from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { parseDelay } from "~/utils/delays";
|
||||
import { handleMetadataPacket } from "~/utils/packets";
|
||||
import { startSpan } from "~/v3/tracing.server";
|
||||
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
|
||||
import { resolveInheritedMintKind } from "~/v3/runOpsMigration/resolveInheritedMintKind.server";
|
||||
import { mintFriendlyIdForKind } from "~/v3/runOpsMigration/mintAnchoredRunFriendlyId.server";
|
||||
import type {
|
||||
TriggerTaskServiceOptions,
|
||||
TriggerTaskServiceResult,
|
||||
} from "../../v3/services/triggerTask.server";
|
||||
import { clampMaxDuration } from "../../v3/utils/maxDuration";
|
||||
import {
|
||||
type IdempotencyKeyConcern,
|
||||
type ClaimedIdempotency,
|
||||
} from "../concerns/idempotencyKeys.server";
|
||||
import {
|
||||
resolveScheduledQueueSplitEnabled,
|
||||
workerQueueForRun,
|
||||
} from "../concerns/workerQueueSplit.server";
|
||||
import { resolveComputeMigration } from "../concerns/computeMigration.server";
|
||||
import { workerRegionRegistry, backingForQueue, regionForQueue } from "~/v3/workerRegions.server";
|
||||
import { globalFlagsRegistry } from "~/v3/globalFlagsRegistry.server";
|
||||
import {
|
||||
publishClaim as publishMollifierClaim,
|
||||
releaseClaim as releaseMollifierClaim,
|
||||
} from "~/v3/mollifier/idempotencyClaim.server";
|
||||
import type {
|
||||
PayloadProcessor,
|
||||
QueueManager,
|
||||
TraceEventConcern,
|
||||
TriggerRacepoints,
|
||||
TriggerRacepointSystem,
|
||||
TriggerTaskRequest,
|
||||
TriggerTaskValidator,
|
||||
} from "../types";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
evaluateGate as defaultEvaluateGate,
|
||||
type GateOutcome,
|
||||
type MollifierEvaluateGate,
|
||||
} from "~/v3/mollifier/mollifierGate.server";
|
||||
import {
|
||||
getMollifierBuffer as defaultGetMollifierBuffer,
|
||||
type MollifierGetBuffer,
|
||||
} from "~/v3/mollifier/mollifierBuffer.server";
|
||||
import { mollifyTrigger } from "~/v3/mollifier/mollifierMollify.server";
|
||||
import { QueueSizeLimitExceededError, ServiceValidationError } from "~/v3/services/common.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
|
||||
class NoopTriggerRacepointSystem implements TriggerRacepointSystem {
|
||||
async waitForRacepoint(options: { racepoint: TriggerRacepoints; id: string }): Promise<void> {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export class RunEngineTriggerTaskService {
|
||||
private readonly queueConcern: QueueManager;
|
||||
private readonly validator: TriggerTaskValidator;
|
||||
private readonly payloadProcessor: PayloadProcessor;
|
||||
private readonly idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
private readonly prisma: PrismaClientOrTransaction;
|
||||
private readonly engine: RunEngine;
|
||||
private readonly tracer: Tracer;
|
||||
private readonly traceEventConcern: TraceEventConcern;
|
||||
private readonly triggerRacepointSystem: TriggerRacepointSystem;
|
||||
private readonly metadataMaximumSize: number;
|
||||
// Mollifier hooks are DI'd so tests can drive the call-site's mollify branch
|
||||
// deterministically (stub the gate to return mollify, inject a real or fake
|
||||
// buffer, force the global-enabled predicate to true so the call site
|
||||
// doesn't short-circuit on an unset env). In production all three default
|
||||
// to the live module-level singletons + env read.
|
||||
private readonly evaluateGate: MollifierEvaluateGate;
|
||||
private readonly getMollifierBuffer: MollifierGetBuffer;
|
||||
private readonly isMollifierGloballyEnabled: () => boolean;
|
||||
|
||||
constructor(opts: {
|
||||
prisma: PrismaClientOrTransaction;
|
||||
engine: RunEngine;
|
||||
queueConcern: QueueManager;
|
||||
validator: TriggerTaskValidator;
|
||||
payloadProcessor: PayloadProcessor;
|
||||
idempotencyKeyConcern: IdempotencyKeyConcern;
|
||||
traceEventConcern: TraceEventConcern;
|
||||
tracer: Tracer;
|
||||
metadataMaximumSize: number;
|
||||
triggerRacepointSystem?: TriggerRacepointSystem;
|
||||
evaluateGate?: MollifierEvaluateGate;
|
||||
getMollifierBuffer?: MollifierGetBuffer;
|
||||
isMollifierGloballyEnabled?: () => boolean;
|
||||
}) {
|
||||
this.prisma = opts.prisma;
|
||||
this.engine = opts.engine;
|
||||
this.queueConcern = opts.queueConcern;
|
||||
this.validator = opts.validator;
|
||||
this.payloadProcessor = opts.payloadProcessor;
|
||||
this.idempotencyKeyConcern = opts.idempotencyKeyConcern;
|
||||
this.tracer = opts.tracer;
|
||||
this.traceEventConcern = opts.traceEventConcern;
|
||||
this.metadataMaximumSize = opts.metadataMaximumSize;
|
||||
this.triggerRacepointSystem = opts.triggerRacepointSystem ?? new NoopTriggerRacepointSystem();
|
||||
this.evaluateGate = opts.evaluateGate ?? defaultEvaluateGate;
|
||||
this.getMollifierBuffer = opts.getMollifierBuffer ?? defaultGetMollifierBuffer;
|
||||
this.isMollifierGloballyEnabled =
|
||||
opts.isMollifierGloballyEnabled ?? (() => env.TRIGGER_MOLLIFIER_ENABLED === "1");
|
||||
}
|
||||
|
||||
// Mint a new run's friendlyId. The id-kind decides which store the run is born
|
||||
// in (cuid → legacy store, run-ops id → new store), so the whole subgraph of a run
|
||||
// must agree. Two cases:
|
||||
//
|
||||
// - ROOT run (no parent): mint by the environment's cutover setting.
|
||||
// - CHILD run (has a parent): inherit the parent's residency by id-shape, so a
|
||||
// parent and child never split across stores (run-ops parent → run-ops child,
|
||||
// cuid parent → cuid child).
|
||||
// `region` is the caller-requested region (body.options.region). The id is
|
||||
// minted before the worker queue is resolved (the idempotency concern needs
|
||||
// the friendlyId first), so the stamped region char reflects the requested
|
||||
// region — or the default char when the run targets the default region.
|
||||
private async mintRunFriendlyId(
|
||||
environment: AuthenticatedEnvironment,
|
||||
parentRunFriendlyId?: string,
|
||||
region?: string
|
||||
): Promise<string> {
|
||||
const mintKind = parentRunFriendlyId
|
||||
? resolveInheritedMintKind(parentRunFriendlyId)
|
||||
: await resolveRunIdMintKind({
|
||||
organizationId: environment.organizationId,
|
||||
id: environment.id,
|
||||
orgFeatureFlags: environment.organization.featureFlags,
|
||||
});
|
||||
|
||||
return mintFriendlyIdForKind(mintKind, region);
|
||||
}
|
||||
|
||||
public async call({
|
||||
taskId,
|
||||
environment,
|
||||
body,
|
||||
options = {},
|
||||
attempt = 0,
|
||||
}: {
|
||||
taskId: string;
|
||||
environment: AuthenticatedEnvironment;
|
||||
body: TriggerTaskRequestBody;
|
||||
options?: TriggerTaskServiceOptions;
|
||||
attempt?: number;
|
||||
}): Promise<TriggerTaskServiceResult | undefined> {
|
||||
// Pre-gate idempotency-claim ownership. Set inside the span when
|
||||
// `IdempotencyKeyConcern.handleTriggerRequest` returns `claim:
|
||||
// {...}`. The try/catch below resolves it once the span finishes.
|
||||
let idempotencyClaim: ClaimedIdempotency | undefined;
|
||||
try {
|
||||
const result = await startSpan(
|
||||
this.tracer,
|
||||
"RunEngineTriggerTaskService.call()",
|
||||
async (span) => {
|
||||
span.setAttribute("taskId", taskId);
|
||||
span.setAttribute("attempt", attempt);
|
||||
|
||||
// Mint the run id. A caller-supplied id (idempotent retry) wins;
|
||||
// otherwise mint by residency — inheriting the parent's store when a
|
||||
// parent is present, else the environment's setting.
|
||||
const runFriendlyId =
|
||||
options?.runFriendlyId ??
|
||||
(await this.mintRunFriendlyId(
|
||||
environment,
|
||||
body.options?.parentRunId,
|
||||
body.options?.region
|
||||
));
|
||||
const triggerRequest = {
|
||||
taskId,
|
||||
friendlyId: runFriendlyId,
|
||||
environment,
|
||||
body,
|
||||
options,
|
||||
} satisfies TriggerTaskRequest;
|
||||
|
||||
const maxAttemptsValidation = this.validator.validateMaxAttempts({
|
||||
taskId,
|
||||
attempt,
|
||||
});
|
||||
|
||||
if (!maxAttemptsValidation.ok) {
|
||||
throw maxAttemptsValidation.error;
|
||||
}
|
||||
|
||||
const tagValidation = this.validator.validateTags({
|
||||
tags: body.options?.tags,
|
||||
});
|
||||
|
||||
if (!tagValidation.ok) {
|
||||
throw tagValidation.error;
|
||||
}
|
||||
|
||||
let planType: string | undefined;
|
||||
|
||||
if (!options.skipChecks) {
|
||||
const entitlementValidation = await this.validator.validateEntitlement({
|
||||
environment,
|
||||
});
|
||||
|
||||
if (!entitlementValidation.ok) {
|
||||
throw entitlementValidation.error;
|
||||
}
|
||||
|
||||
planType = entitlementValidation.plan?.type;
|
||||
} else {
|
||||
// When skipChecks is enabled, planType should be passed via options
|
||||
planType = options.planType;
|
||||
|
||||
if (!planType) {
|
||||
logger.warn("Plan type not set but skipChecks is enabled", {
|
||||
taskId,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
projectId: environment.projectId,
|
||||
organizationId: environment.organizationId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Parse delay from either explicit delay option or debounce.delay
|
||||
const delaySource = body.options?.delay ?? body.options?.debounce?.delay;
|
||||
const [parseDelayError, delayUntil] = await tryCatch(parseDelay(delaySource));
|
||||
|
||||
if (parseDelayError) {
|
||||
throw new ServiceValidationError(`Invalid delay ${delaySource}`);
|
||||
}
|
||||
|
||||
// Validate debounce options
|
||||
if (body.options?.debounce) {
|
||||
if (!delayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Debounce requires a valid delay duration. Provided: ${body.options.debounce.delay}`
|
||||
);
|
||||
}
|
||||
|
||||
// Always validate debounce.delay separately since it's used for rescheduling
|
||||
// This catches the case where options.delay is valid but debounce.delay is invalid
|
||||
const [debounceDelayError, debounceDelayUntil] = await tryCatch(
|
||||
parseDelay(body.options.debounce.delay)
|
||||
);
|
||||
|
||||
if (debounceDelayError || !debounceDelayUntil) {
|
||||
throw new ServiceValidationError(
|
||||
`Invalid debounce delay: ${body.options.debounce.delay}. ` +
|
||||
`Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const parentRun = body.options?.parentRunId
|
||||
? await runStore.findRun(
|
||||
{
|
||||
id: RunId.fromFriendlyId(body.options.parentRunId),
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
this.prisma
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const parentRunValidation = this.validator.validateParentRun({
|
||||
taskId,
|
||||
parentRun: parentRun ?? undefined,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
});
|
||||
|
||||
if (!parentRunValidation.ok) {
|
||||
throw parentRunValidation.error;
|
||||
}
|
||||
|
||||
const idempotencyKeyConcernResult = await this.idempotencyKeyConcern.handleTriggerRequest(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore
|
||||
);
|
||||
|
||||
if (idempotencyKeyConcernResult.isCached) {
|
||||
return idempotencyKeyConcernResult;
|
||||
}
|
||||
|
||||
const {
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt,
|
||||
claim: claimResult,
|
||||
} = idempotencyKeyConcernResult;
|
||||
|
||||
// If we own an idempotency claim, the trigger pipeline below MUST
|
||||
// resolve it — publish on success so waiters see our runId,
|
||||
// release on error so the next claimant can retry. Stored in an
|
||||
// outer scope so the try/catch at the bottom of `callV2` can act
|
||||
// on whichever return path or throw the pipeline takes.
|
||||
idempotencyClaim = claimResult;
|
||||
|
||||
if (idempotencyKey) {
|
||||
await this.triggerRacepointSystem.waitForRacepoint({
|
||||
racepoint: "idempotencyKey",
|
||||
id: idempotencyKey,
|
||||
});
|
||||
}
|
||||
|
||||
const lockedToBackgroundWorker = body.options?.lockToVersion
|
||||
? await this.prisma.backgroundWorker.findFirst({
|
||||
where: {
|
||||
projectId: environment.projectId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
version: body.options?.lockToVersion,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
sdkVersion: true,
|
||||
cliVersion: true,
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const { queueName, lockedQueueId, taskTtl, taskKind } =
|
||||
await this.queueConcern.resolveQueueProperties(
|
||||
triggerRequest,
|
||||
lockedToBackgroundWorker ?? undefined
|
||||
);
|
||||
|
||||
// Resolve TTL with precedence: per-trigger > task-level > dev default
|
||||
let ttl: string | undefined;
|
||||
|
||||
if (body.options?.ttl !== undefined) {
|
||||
ttl =
|
||||
typeof body.options.ttl === "number"
|
||||
? stringifyDuration(body.options.ttl)
|
||||
: body.options.ttl;
|
||||
} else {
|
||||
ttl = taskTtl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined);
|
||||
}
|
||||
|
||||
if (!options.skipChecks) {
|
||||
const queueSizeGuard = await this.queueConcern.validateQueueLimits(
|
||||
environment,
|
||||
queueName
|
||||
);
|
||||
|
||||
if (!queueSizeGuard.ok) {
|
||||
throw new QueueSizeLimitExceededError(
|
||||
`Cannot trigger ${taskId} as the queue size limit for this environment has been reached. The maximum size is ${queueSizeGuard.maximumSize}`,
|
||||
queueSizeGuard.maximumSize ?? 0,
|
||||
undefined,
|
||||
"warn"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const metadataPacket = body.options?.metadata
|
||||
? handleMetadataPacket(
|
||||
body.options?.metadata,
|
||||
body.options?.metadataType ?? "application/json",
|
||||
this.metadataMaximumSize
|
||||
)
|
||||
: undefined;
|
||||
|
||||
const tags = (
|
||||
body.options?.tags
|
||||
? typeof body.options.tags === "string"
|
||||
? [body.options.tags]
|
||||
: body.options.tags
|
||||
: []
|
||||
).filter((tag) => tag.trim().length > 0);
|
||||
|
||||
const depth = parentRun ? parentRun.depth + 1 : 0;
|
||||
|
||||
const workerQueueResult = await this.queueConcern.getWorkerQueue(
|
||||
environment,
|
||||
body.options?.region
|
||||
);
|
||||
const baseWorkerQueue = workerQueueResult?.masterQueue;
|
||||
const enableFastPath = workerQueueResult?.enableFastPath ?? false;
|
||||
|
||||
// Rewrite the region to its compute backing for migration-enrolled orgs,
|
||||
// from the in-memory snapshots (no DB query). A cold read (registry not yet
|
||||
// loaded) returns undefined/[] and the resolver falls back to not-migrated.
|
||||
const workerGroups = workerRegionRegistry.current() ?? [];
|
||||
const region = baseWorkerQueue
|
||||
? regionForQueue(baseWorkerQueue, workerGroups)
|
||||
: undefined;
|
||||
const backing = baseWorkerQueue
|
||||
? backingForQueue(baseWorkerQueue, workerGroups)
|
||||
: undefined;
|
||||
const migrated = resolveComputeMigration({
|
||||
baseWorkerQueue,
|
||||
baseEnableFastPath: enableFastPath,
|
||||
region,
|
||||
backing,
|
||||
planType,
|
||||
orgId: environment.organization.id,
|
||||
orgFeatureFlags: environment.organization.featureFlags as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
flags: globalFlagsRegistry.current(),
|
||||
envType: environment.type,
|
||||
});
|
||||
|
||||
const triggerSource = options.triggerSource ?? "api";
|
||||
const triggerAction = options.triggerAction ?? "trigger";
|
||||
const parentAnnotations = RunAnnotations.safeParse(parentRun?.annotations).data;
|
||||
const annotations = {
|
||||
triggerSource,
|
||||
triggerAction,
|
||||
rootTriggerSource: parentAnnotations?.rootTriggerSource ?? triggerSource,
|
||||
rootScheduleId: parentAnnotations?.rootScheduleId || options.scheduleId || undefined,
|
||||
taskKind: taskKind ?? "STANDARD",
|
||||
};
|
||||
|
||||
// Route runs in a scheduled lineage (the scheduled run itself and every
|
||||
// descendant, via the propagated rootTriggerSource) to a dedicated
|
||||
// `<region>:scheduled` worker queue so a separate consumer fleet can
|
||||
// dequeue them independently of standard/agent runs. Gated per-org with
|
||||
// a global default, never applied to dev. Reads only the in-memory org
|
||||
// flags already on the environment — no DB query on the hot path.
|
||||
const scheduledQueueSplitEnabled =
|
||||
environment.type !== "DEVELOPMENT" &&
|
||||
resolveScheduledQueueSplitEnabled({
|
||||
orgFeatureFlags: environment.organization.featureFlags as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null,
|
||||
globalDefault: env.TRIGGER_WORKER_QUEUE_SCHEDULED_SPLIT_ENABLED === "1",
|
||||
});
|
||||
const workerQueue =
|
||||
migrated.workerQueue !== undefined
|
||||
? workerQueueForRun({
|
||||
workerQueue: migrated.workerQueue,
|
||||
rootTriggerSource: annotations.rootTriggerSource,
|
||||
splitEnabled: scheduledQueueSplitEnabled,
|
||||
})
|
||||
: migrated.workerQueue;
|
||||
|
||||
try {
|
||||
return await this.traceEventConcern.traceRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
async (event, store) => {
|
||||
event.setAttribute("queueName", queueName);
|
||||
span.setAttribute("queueName", queueName);
|
||||
event.setAttribute("runId", runFriendlyId);
|
||||
span.setAttribute("runId", runFriendlyId);
|
||||
|
||||
// Short-circuit when mollifier is globally off (the default
|
||||
// for every deployment that hasn't opted in). Avoids the
|
||||
// GateInputs allocation, the deps spread inside `evaluateGate`,
|
||||
// and the `mollifier.decisions{outcome=pass_through}` OTel
|
||||
// increment on every trigger — `triggerTask` is the
|
||||
// highest-throughput code path in the system. The check goes
|
||||
// through a DI'd predicate so unit tests that inject a custom
|
||||
// `evaluateGate` can also override the gate-on check (the
|
||||
// default reads `env.TRIGGER_MOLLIFIER_ENABLED`, which is "0"
|
||||
// in CI where no .env file is present).
|
||||
//
|
||||
// Batch items bypass the mollifier gate entirely.
|
||||
//
|
||||
// The mollify path returns a stripped run-shape `{ id,
|
||||
// friendlyId, spanId }` with no PG row written. Batch
|
||||
// tracking relies on `BatchTaskRunItem`, a join row whose
|
||||
// `taskRunId` column has a NOT NULL FK to `TaskRun.id` —
|
||||
// creating that join at trigger-time (in
|
||||
// `batchTriggerV3.server.ts:871`) fails with FK violation
|
||||
// for any mollified item, and skipping it at trigger-time
|
||||
// would silently drop the batch↔run link forever because
|
||||
// the drainer's materialise path doesn't (yet) create
|
||||
// `BatchTaskRunItem`. Either side alone is wrong:
|
||||
// - skip at trigger-time only → batch progress
|
||||
// under-reports forever, `batchTriggerAndWait` parent
|
||||
// stays parked
|
||||
// - mollify at trigger-time only → FK violation, 500
|
||||
//
|
||||
// The proper end state is a drainer-side
|
||||
// `BatchTaskRunItem` create-on-materialise (the snapshot
|
||||
// already carries `batch: { id, index }` so the drainer
|
||||
// has the info). That belongs in the drainer / replay PR,
|
||||
// not here. Until that lands, batch triggers pass-through
|
||||
// — they lose the burst-protection benefit, but the path
|
||||
// works end-to-end.
|
||||
const skipMollifierForBatch = !!options.batchId;
|
||||
const mollifierOutcome: GateOutcome | null =
|
||||
this.isMollifierGloballyEnabled() && !skipMollifierForBatch
|
||||
? await this.evaluateGate({
|
||||
envId: environment.id,
|
||||
orgId: environment.organizationId,
|
||||
taskId,
|
||||
orgFeatureFlags:
|
||||
(environment.organization.featureFlags as Record<
|
||||
string,
|
||||
unknown
|
||||
> | null) ?? null,
|
||||
options: {
|
||||
debounce: body.options?.debounce,
|
||||
oneTimeUseToken: options.oneTimeUseToken,
|
||||
parentTaskRunId: body.options?.parentRunId,
|
||||
resumeParentOnCompletion: body.options?.resumeParentOnCompletion,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
// When the gate says mollify, write the engine.trigger input
|
||||
// snapshot into the Redis buffer and return a synthesised
|
||||
// TriggerTaskServiceResult. The customer never waits on
|
||||
// Postgres; the drainer materialises the run later by replaying
|
||||
// engine.trigger against the snapshot. The run span has already
|
||||
// been opened by traceRun above (PARTIAL event in ClickHouse),
|
||||
// so its traceId/spanId live in the snapshot and the drainer's
|
||||
// `mollifier.drained` span parents on the same trace — buffered
|
||||
// runs become visible in the dashboard's trace view immediately,
|
||||
// not only after the drainer fires.
|
||||
if (mollifierOutcome?.action === "mollify") {
|
||||
const mollifierBuffer = this.getMollifierBuffer();
|
||||
if (mollifierBuffer && !body.options?.debounce) {
|
||||
event.setAttribute("mollifier.reason", mollifierOutcome.decision.reason);
|
||||
event.setAttribute("mollifier.count", String(mollifierOutcome.decision.count));
|
||||
event.setAttribute(
|
||||
"mollifier.threshold",
|
||||
String(mollifierOutcome.decision.threshold)
|
||||
);
|
||||
event.setAttribute("taskRunId", runFriendlyId);
|
||||
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
|
||||
const engineTriggerInput = this.#buildEngineTriggerInput({
|
||||
runFriendlyId,
|
||||
environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt,
|
||||
body,
|
||||
options,
|
||||
queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
region: migrated.region,
|
||||
enableFastPath: migrated.enableFastPath,
|
||||
lockedToBackgroundWorker: lockedToBackgroundWorker ?? undefined,
|
||||
delayUntil,
|
||||
ttl,
|
||||
metadataPacket,
|
||||
tags,
|
||||
depth,
|
||||
parentRun: parentRun ?? undefined,
|
||||
annotations,
|
||||
planType,
|
||||
taskId,
|
||||
payloadPacket,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay"
|
||||
? undefined
|
||||
: event.traceparent?.spanId,
|
||||
taskEventStore: store,
|
||||
});
|
||||
|
||||
const result = await mollifyTrigger({
|
||||
runFriendlyId,
|
||||
environmentId: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
engineTriggerInput,
|
||||
decision: mollifierOutcome.decision,
|
||||
buffer: mollifierBuffer,
|
||||
// Idempotency-key triple wires the buffer's SETNX into
|
||||
// the trigger-time dedup symmetric with PG.
|
||||
idempotencyKey,
|
||||
taskIdentifier: taskId,
|
||||
});
|
||||
|
||||
logger.debug("mollifier.buffered", {
|
||||
runId: runFriendlyId,
|
||||
envId: environment.id,
|
||||
orgId: environment.organizationId,
|
||||
taskId,
|
||||
reason: mollifierOutcome.decision.reason,
|
||||
});
|
||||
|
||||
// Synthetic result is structurally narrower than the full
|
||||
// TaskRun; the route handler only reads
|
||||
// `result.run.friendlyId`. traceRun flushes the PARTIAL
|
||||
// run-span event to ClickHouse on callback return.
|
||||
// `isMollified` flags the route to skip the request-
|
||||
// idempotency cache write — see the field's contract on
|
||||
// `TriggerTaskServiceResult`.
|
||||
return {
|
||||
...(result as unknown as TriggerTaskServiceResult),
|
||||
isMollified: true,
|
||||
};
|
||||
}
|
||||
if (!mollifierBuffer) {
|
||||
logger.warn(
|
||||
"mollifier gate said mollify but buffer is null — falling through to pass-through"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const payloadPacket = await this.payloadProcessor.process(triggerRequest);
|
||||
|
||||
const baseEngineInput = this.#buildEngineTriggerInput({
|
||||
runFriendlyId,
|
||||
environment,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt,
|
||||
body,
|
||||
options,
|
||||
queueName,
|
||||
lockedQueueId,
|
||||
workerQueue,
|
||||
region: migrated.region,
|
||||
enableFastPath: migrated.enableFastPath,
|
||||
lockedToBackgroundWorker: lockedToBackgroundWorker ?? undefined,
|
||||
delayUntil,
|
||||
ttl,
|
||||
metadataPacket,
|
||||
tags,
|
||||
depth,
|
||||
parentRun: parentRun ?? undefined,
|
||||
annotations,
|
||||
planType,
|
||||
taskId,
|
||||
payloadPacket,
|
||||
traceContext: this.#propagateExternalTraceContext(
|
||||
event.traceContext,
|
||||
parentRun?.traceContext,
|
||||
event.traceparent?.spanId
|
||||
),
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
parentSpanId:
|
||||
options.parentAsLinkType === "replay" ? undefined : event.traceparent?.spanId,
|
||||
taskEventStore: store,
|
||||
});
|
||||
|
||||
const taskRun = await this.engine.trigger(
|
||||
{
|
||||
...baseEngineInput,
|
||||
// onDebounced is a closure over webapp state (triggerRequest +
|
||||
// traceEventConcern) and can't be serialised into the mollifier
|
||||
// snapshot. The pass-through path attaches it here; the drainer
|
||||
// path replays without it. The debounce and triggerAndWait gate
|
||||
// bypasses ensure neither reaches the mollify branch.
|
||||
onDebounced:
|
||||
body.options?.debounce && body.options?.resumeParentOnCompletion
|
||||
? async ({ existingRun, waitpoint, debounceKey }) => {
|
||||
return await this.traceEventConcern.traceDebouncedRun(
|
||||
triggerRequest,
|
||||
parentRun?.taskEventStore,
|
||||
{
|
||||
existingRun,
|
||||
debounceKey,
|
||||
incomplete: waitpoint.status === "PENDING",
|
||||
isError: waitpoint.outputIsError,
|
||||
},
|
||||
async (spanEvent) => {
|
||||
const spanId =
|
||||
options?.parentAsLinkType === "replay"
|
||||
? spanEvent.spanId
|
||||
: spanEvent.traceparent?.spanId
|
||||
? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}`
|
||||
: spanEvent.spanId;
|
||||
return spanId;
|
||||
}
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
this.prisma
|
||||
);
|
||||
|
||||
// If the returned run has a different friendlyId, it was debounced.
|
||||
// For triggerAndWait: stop the outer span since a replacement debounced span was created via onDebounced.
|
||||
// For regular trigger: let the span complete normally - no replacement span needed since the
|
||||
// original run already has its span from when it was first created.
|
||||
if (
|
||||
taskRun.friendlyId !== runFriendlyId &&
|
||||
body.options?.debounce &&
|
||||
body.options?.resumeParentOnCompletion
|
||||
) {
|
||||
event.stop();
|
||||
}
|
||||
|
||||
const error = taskRun.error ? TaskRunError.parse(taskRun.error) : undefined;
|
||||
|
||||
if (error) {
|
||||
event.failWithError(error);
|
||||
}
|
||||
|
||||
const result = { run: taskRun, error, isCached: false };
|
||||
|
||||
if (result?.error) {
|
||||
throw new ServiceValidationError(
|
||||
taskRunErrorToString(taskRunErrorEnhancer(result.error))
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof RunDuplicateIdempotencyKeyError) {
|
||||
//retry calling this function, because this time it will return the idempotent run
|
||||
return await this.call({
|
||||
taskId,
|
||||
environment,
|
||||
body,
|
||||
options: { ...options, runFriendlyId },
|
||||
attempt: attempt + 1,
|
||||
});
|
||||
}
|
||||
|
||||
if (error instanceof RunOneTimeUseTokenError) {
|
||||
throw new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} with a one-time use token as it has already been used.`
|
||||
);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
// Pipeline returned successfully — publish the claim if we held
|
||||
// one. Waiters polling for our key resolve to this runId.
|
||||
if (idempotencyClaim && result?.run?.friendlyId) {
|
||||
await publishMollifierClaim({
|
||||
envId: idempotencyClaim.envId,
|
||||
taskIdentifier: idempotencyClaim.taskIdentifier,
|
||||
idempotencyKey: idempotencyClaim.idempotencyKey,
|
||||
token: idempotencyClaim.token,
|
||||
runId: result.run.friendlyId,
|
||||
ttlSeconds: env.TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
// Pipeline threw — release the claim so the next claimant can
|
||||
// retry. Re-throw so the caller sees the original error.
|
||||
if (idempotencyClaim) {
|
||||
await releaseMollifierClaim(idempotencyClaim);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the engine.trigger() input object from the values gathered during
|
||||
// this.call(). Extracted so the mollify path can construct the
|
||||
// same input shape without re-entering the trace-run span. The pass-through
|
||||
// path spreads this result and attaches `onDebounced` inline; the mollify
|
||||
// path serialises it into the buffer for drainer replay.
|
||||
#buildEngineTriggerInput(args: {
|
||||
runFriendlyId: string;
|
||||
environment: AuthenticatedEnvironment;
|
||||
idempotencyKey?: string;
|
||||
idempotencyKeyExpiresAt?: Date;
|
||||
body: TriggerTaskRequest["body"];
|
||||
options: TriggerTaskServiceOptions;
|
||||
queueName: string;
|
||||
lockedQueueId?: string;
|
||||
workerQueue?: string;
|
||||
region?: string;
|
||||
enableFastPath: boolean;
|
||||
lockedToBackgroundWorker?: {
|
||||
id: string;
|
||||
version: string;
|
||||
sdkVersion: string;
|
||||
cliVersion: string;
|
||||
};
|
||||
delayUntil?: Date;
|
||||
ttl?: string;
|
||||
metadataPacket?: { data?: string; dataType: string };
|
||||
tags: string[];
|
||||
depth: number;
|
||||
parentRun?: {
|
||||
id: string;
|
||||
rootTaskRunId?: string | null;
|
||||
queueTimestamp?: Date | null;
|
||||
taskEventStore?: string;
|
||||
};
|
||||
annotations: {
|
||||
triggerSource: string;
|
||||
triggerAction: string;
|
||||
rootTriggerSource: string;
|
||||
rootScheduleId?: string | undefined;
|
||||
};
|
||||
planType?: string;
|
||||
taskId: string;
|
||||
payloadPacket: { data?: string; dataType: string };
|
||||
traceContext: TriggerTraceContext;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
parentSpanId: string | undefined;
|
||||
taskEventStore: string;
|
||||
}) {
|
||||
return {
|
||||
friendlyId: args.runFriendlyId,
|
||||
environment: args.environment,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
idempotencyKeyExpiresAt: args.idempotencyKey ? args.idempotencyKeyExpiresAt : undefined,
|
||||
idempotencyKeyOptions: args.body.options?.idempotencyKeyOptions,
|
||||
taskIdentifier: args.taskId,
|
||||
payload: args.payloadPacket.data ?? "",
|
||||
payloadType: args.payloadPacket.dataType,
|
||||
context: args.body.context,
|
||||
traceContext: args.traceContext,
|
||||
traceId: args.traceId,
|
||||
spanId: args.spanId,
|
||||
parentSpanId: args.parentSpanId,
|
||||
replayedFromTaskRunFriendlyId: args.options.replayedFromTaskRunFriendlyId,
|
||||
lockedToVersionId: args.lockedToBackgroundWorker?.id,
|
||||
taskVersion: args.lockedToBackgroundWorker?.version,
|
||||
sdkVersion: args.lockedToBackgroundWorker?.sdkVersion,
|
||||
cliVersion: args.lockedToBackgroundWorker?.cliVersion,
|
||||
// Schema-level coercion now lands `body.options.concurrencyKey` as
|
||||
// `string` on the API path, but the BatchQueue worker rebuilds
|
||||
// body.options from Redis-stored items (Record<string, unknown>),
|
||||
// which can still carry the pre-fix shape from in-flight batches.
|
||||
concurrencyKey:
|
||||
typeof args.body.options?.concurrencyKey === "number"
|
||||
? String(args.body.options.concurrencyKey)
|
||||
: args.body.options?.concurrencyKey,
|
||||
queue: args.queueName,
|
||||
lockedQueueId: args.lockedQueueId,
|
||||
workerQueue: args.workerQueue,
|
||||
region: args.region,
|
||||
enableFastPath: args.enableFastPath,
|
||||
isTest: args.body.options?.test ?? false,
|
||||
delayUntil: args.delayUntil,
|
||||
queuedAt: args.delayUntil ? undefined : new Date(),
|
||||
maxAttempts: args.body.options?.maxAttempts,
|
||||
taskEventStore: args.taskEventStore,
|
||||
ttl: args.ttl,
|
||||
tags: args.tags,
|
||||
oneTimeUseToken: args.options.oneTimeUseToken,
|
||||
parentTaskRunId: args.parentRun?.id,
|
||||
rootTaskRunId: args.parentRun?.rootTaskRunId ?? args.parentRun?.id,
|
||||
batch: args.options?.batchId
|
||||
? { id: args.options.batchId, index: args.options.batchIndex ?? 0 }
|
||||
: undefined,
|
||||
resumeParentOnCompletion: args.body.options?.resumeParentOnCompletion,
|
||||
depth: args.depth,
|
||||
metadata: args.metadataPacket?.data,
|
||||
metadataType: args.metadataPacket?.dataType,
|
||||
seedMetadata: args.metadataPacket?.data,
|
||||
seedMetadataType: args.metadataPacket?.dataType,
|
||||
maxDurationInSeconds: args.body.options?.maxDuration
|
||||
? clampMaxDuration(args.body.options.maxDuration)
|
||||
: undefined,
|
||||
machine: args.body.options?.machine,
|
||||
priorityMs: args.body.options?.priority ? args.body.options.priority * 1_000 : undefined,
|
||||
queueTimestamp:
|
||||
args.options.queueTimestamp ??
|
||||
(args.parentRun && args.body.options?.resumeParentOnCompletion
|
||||
? (args.parentRun.queueTimestamp ?? undefined)
|
||||
: undefined),
|
||||
scheduleId: args.options.scheduleId,
|
||||
scheduleInstanceId: args.options.scheduleInstanceId,
|
||||
createdAt: args.options.overrideCreatedAt,
|
||||
bulkActionId: args.body.options?.bulkActionId,
|
||||
planType: args.planType,
|
||||
realtimeStreamsVersion: args.options.realtimeStreamsVersion,
|
||||
streamBasinName: args.environment.organization.streamBasinName,
|
||||
debounce: args.body.options?.debounce,
|
||||
annotations: args.annotations,
|
||||
};
|
||||
}
|
||||
|
||||
#propagateExternalTraceContext(
|
||||
traceContext: Record<string, unknown>,
|
||||
parentRunTraceContext: unknown,
|
||||
parentSpanId: string | undefined
|
||||
): TriggerTraceContext {
|
||||
if (!parentRunTraceContext) {
|
||||
return traceContext;
|
||||
}
|
||||
|
||||
const parsedParentRunTraceContext = TriggerTraceContext.safeParse(parentRunTraceContext);
|
||||
|
||||
if (!parsedParentRunTraceContext.success) {
|
||||
return traceContext;
|
||||
}
|
||||
|
||||
const { external } = parsedParentRunTraceContext.data;
|
||||
|
||||
if (!external) {
|
||||
return traceContext;
|
||||
}
|
||||
|
||||
if (!external.traceparent) {
|
||||
return traceContext;
|
||||
}
|
||||
|
||||
const parsedTraceparent = parseTraceparent(external.traceparent);
|
||||
|
||||
if (!parsedTraceparent) {
|
||||
return traceContext;
|
||||
}
|
||||
|
||||
const newExternalTraceparent = serializeTraceparent(
|
||||
parsedTraceparent.traceId,
|
||||
parentSpanId ?? parsedTraceparent.spanId,
|
||||
parsedTraceparent.traceFlags
|
||||
);
|
||||
|
||||
return {
|
||||
...traceContext,
|
||||
external: {
|
||||
...external,
|
||||
traceparent: newExternalTraceparent,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user