chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import type { GlobalRateLimiter } from "@trigger.dev/redis-worker";
|
||||
import { RateLimiter } from "~/services/rateLimiter.server";
|
||||
|
||||
/**
|
||||
* Creates a global rate limiter for the batch queue that limits
|
||||
* the maximum number of items processed per second across all consumers.
|
||||
*
|
||||
* Uses a token bucket algorithm where:
|
||||
* - `itemsPerSecond` tokens are available per second
|
||||
* - The bucket can hold up to `itemsPerSecond` tokens (burst capacity)
|
||||
*
|
||||
* @param itemsPerSecond - Maximum items to process per second
|
||||
* @returns A GlobalRateLimiter compatible with FairQueue
|
||||
*/
|
||||
export function createBatchGlobalRateLimiter(itemsPerSecond: number): GlobalRateLimiter {
|
||||
const limiter = new RateLimiter({
|
||||
keyPrefix: "batch-queue-global",
|
||||
// Token bucket: refills `itemsPerSecond` tokens every second
|
||||
// Bucket capacity is also `itemsPerSecond` (allows burst up to limit)
|
||||
limiter: Ratelimit.tokenBucket(itemsPerSecond, "1 s", itemsPerSecond),
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
|
||||
return {
|
||||
async limit() {
|
||||
const result = await limiter.limit("global");
|
||||
return {
|
||||
allowed: result.success,
|
||||
resetAt: result.reset,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
createLimiterFromConfig,
|
||||
RateLimiterConfig,
|
||||
} from "~/services/authorizationRateLimitMiddleware.server";
|
||||
import type { Duration } from "~/services/rateLimiter.server";
|
||||
import { createRedisRateLimitClient, RateLimiter } from "~/services/rateLimiter.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
const BatchLimitsConfig = z.object({
|
||||
processingConcurrency: z.number().int().default(env.BATCH_CONCURRENCY_LIMIT_DEFAULT),
|
||||
});
|
||||
|
||||
/**
|
||||
* Batch limits configuration for a plan type
|
||||
*/
|
||||
export type BatchLimitsConfig = z.infer<typeof BatchLimitsConfig>;
|
||||
|
||||
const batchLimitsRedisClient = singleton("batchLimitsRedisClient", createBatchLimitsRedisClient);
|
||||
|
||||
function createBatchLimitsRedisClient() {
|
||||
const redisClient = createRedisRateLimitClient({
|
||||
port: env.RATE_LIMIT_REDIS_PORT,
|
||||
host: env.RATE_LIMIT_REDIS_HOST,
|
||||
username: env.RATE_LIMIT_REDIS_USERNAME,
|
||||
password: env.RATE_LIMIT_REDIS_PASSWORD,
|
||||
tlsDisabled: env.RATE_LIMIT_REDIS_TLS_DISABLED === "true",
|
||||
clusterMode: env.RATE_LIMIT_REDIS_CLUSTER_MODE_ENABLED === "1",
|
||||
});
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
// Just the org fields this module reads. Compatible with both the full
|
||||
// Prisma `Organization` payload and the slim `AuthenticatedEnvironment`
|
||||
// `["organization"]` shape (when passed `batchRateLimitConfig` /
|
||||
// `batchQueueConcurrencyConfig` as `unknown`).
|
||||
type OrganizationForBatchLimits = {
|
||||
batchRateLimitConfig?: unknown;
|
||||
batchQueueConcurrencyConfig?: unknown;
|
||||
};
|
||||
|
||||
function createOrganizationRateLimiter(organization: OrganizationForBatchLimits): RateLimiter {
|
||||
const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig);
|
||||
|
||||
const limiter = createLimiterFromConfig(limiterConfig);
|
||||
|
||||
return new RateLimiter({
|
||||
redisClient: batchLimitsRedisClient,
|
||||
keyPrefix: "ratelimit:batch",
|
||||
limiter,
|
||||
logSuccess: false,
|
||||
logFailure: true,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimiterConfig {
|
||||
const defaultRateLimiterConfig: RateLimiterConfig = {
|
||||
type: "tokenBucket",
|
||||
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.BATCH_RATE_LIMIT_MAX,
|
||||
};
|
||||
|
||||
if (!batchRateLimitConfig) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
const parsedBatchRateLimitConfig = RateLimiterConfig.safeParse(batchRateLimitConfig);
|
||||
|
||||
if (!parsedBatchRateLimitConfig.success) {
|
||||
return defaultRateLimiterConfig;
|
||||
}
|
||||
|
||||
return parsedBatchRateLimitConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the rate limiter and limits for an organization.
|
||||
* Internally looks up the plan type, but doesn't expose it to callers.
|
||||
*/
|
||||
export async function getBatchLimits(
|
||||
organization: OrganizationForBatchLimits
|
||||
): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> {
|
||||
const rateLimiter = createOrganizationRateLimiter(organization);
|
||||
const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig);
|
||||
return { rateLimiter, config };
|
||||
}
|
||||
|
||||
function resolveBatchLimitsConfig(batchLimitsConfig?: unknown): BatchLimitsConfig {
|
||||
const defaultLimitsConfig: BatchLimitsConfig = {
|
||||
processingConcurrency: env.BATCH_CONCURRENCY_LIMIT_DEFAULT,
|
||||
};
|
||||
|
||||
if (!batchLimitsConfig) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
const parsedBatchLimitsConfig = BatchLimitsConfig.safeParse(batchLimitsConfig);
|
||||
|
||||
if (!parsedBatchLimitsConfig.success) {
|
||||
return defaultLimitsConfig;
|
||||
}
|
||||
|
||||
return parsedBatchLimitsConfig.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when batch rate limit is exceeded.
|
||||
* Contains information for constructing a proper 429 response.
|
||||
*/
|
||||
export class BatchRateLimitExceededError extends Error {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
public readonly remaining: number,
|
||||
public readonly resetAt: Date,
|
||||
public readonly itemCount: number
|
||||
) {
|
||||
super(`Batch rate limit exceeded. Limit resets at ${resetAt.toISOString()}`);
|
||||
this.name = "BatchRateLimitExceededError";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { type IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
||||
import pRetry from "p-retry";
|
||||
import { env } from "~/env.server";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { hasObjectStoreClient, uploadPacketToObjectStore } from "~/v3/objectStore.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
export type BatchPayloadProcessResult = {
|
||||
/** The processed payload - either the original or an R2 path */
|
||||
payload: unknown;
|
||||
/** The payload type - "application/store" if offloaded to R2 */
|
||||
payloadType: string;
|
||||
/** Whether the payload was offloaded to R2 */
|
||||
wasOffloaded: boolean;
|
||||
/** Size of the payload in bytes */
|
||||
size: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BatchPayloadProcessor handles payload offloading for batch items.
|
||||
*
|
||||
* When a batch item's payload exceeds the configured threshold, it's uploaded
|
||||
* to object storage (R2) and the payload is replaced with the storage path.
|
||||
* This aligns with how single task triggers work via DefaultPayloadProcessor.
|
||||
*
|
||||
* Path format: batch_{batchId}/item_{index}/payload.json
|
||||
*/
|
||||
export class BatchPayloadProcessor {
|
||||
/**
|
||||
* Check if object storage is available for payload offloading.
|
||||
* If not available, large payloads will be stored inline (which may fail for very large payloads).
|
||||
*/
|
||||
isObjectStoreAvailable(): boolean {
|
||||
return hasObjectStoreClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a batch item payload, offloading to R2 if it exceeds the threshold.
|
||||
*
|
||||
* @param payload - The raw payload from the batch item
|
||||
* @param payloadType - The payload type (e.g., "application/json")
|
||||
* @param batchId - The batch ID (internal format)
|
||||
* @param itemIndex - The item index within the batch
|
||||
* @param environment - The authenticated environment for R2 path construction
|
||||
* @returns The processed result with potentially offloaded payload
|
||||
*/
|
||||
async process(
|
||||
payload: unknown,
|
||||
payloadType: string,
|
||||
batchId: string,
|
||||
itemIndex: number,
|
||||
environment: AuthenticatedEnvironment
|
||||
): Promise<BatchPayloadProcessResult> {
|
||||
return startActiveSpan("BatchPayloadProcessor.process()", async (span) => {
|
||||
span.setAttribute("batchId", batchId);
|
||||
span.setAttribute("itemIndex", itemIndex);
|
||||
span.setAttribute("payloadType", payloadType);
|
||||
|
||||
// Create the packet for size checking
|
||||
const packet = this.#createPayloadPacket(payload, payloadType);
|
||||
|
||||
if (!packet.data) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const threshold = env.BATCH_PAYLOAD_OFFLOAD_THRESHOLD ?? env.TASK_PAYLOAD_OFFLOAD_THRESHOLD;
|
||||
const { needsOffloading, size } = packetRequiresOffloading(packet, threshold);
|
||||
|
||||
span.setAttribute("payloadSize", size);
|
||||
span.setAttribute("needsOffloading", needsOffloading);
|
||||
span.setAttribute("threshold", threshold);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if object store is available
|
||||
if (!this.isObjectStoreAvailable()) {
|
||||
logger.warn("Payload exceeds threshold but object store is not available", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
size,
|
||||
threshold,
|
||||
});
|
||||
|
||||
// Return without offloading - the payload will be stored inline
|
||||
// This may fail downstream for very large payloads
|
||||
return {
|
||||
payload,
|
||||
payloadType,
|
||||
wasOffloaded: false,
|
||||
size,
|
||||
};
|
||||
}
|
||||
|
||||
// Upload to object store, retrying on transient network errors
|
||||
const { data: packetData, dataType: packetDataType } = packet;
|
||||
const filename = `batch_${batchId}/item_${itemIndex}/payload.json`;
|
||||
|
||||
const [uploadError, uploadedFilename] = await tryCatch(
|
||||
pRetry(
|
||||
() =>
|
||||
uploadPacketToObjectStore(
|
||||
filename,
|
||||
packetData,
|
||||
packetDataType,
|
||||
environment,
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL
|
||||
),
|
||||
{
|
||||
retries: 3,
|
||||
minTimeout: 500,
|
||||
maxTimeout: 2000,
|
||||
factor: 2,
|
||||
onFailedAttempt: (error) => {
|
||||
logger.warn("Batch item payload upload to object store failed, retrying", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
attempt: error.attemptNumber,
|
||||
retriesLeft: error.retriesLeft,
|
||||
error: error.message,
|
||||
});
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
logger.error("Failed to upload batch item payload to object store after retries", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
error: uploadError.message,
|
||||
});
|
||||
|
||||
throw new Error(`Failed to upload large payload to object store: ${uploadError.message}`);
|
||||
}
|
||||
|
||||
logger.debug("Batch item payload offloaded to object store", {
|
||||
batchId,
|
||||
itemIndex,
|
||||
filename: uploadedFilename,
|
||||
size,
|
||||
});
|
||||
|
||||
span.setAttribute("wasOffloaded", true);
|
||||
span.setAttribute("offloadPath", uploadedFilename);
|
||||
|
||||
return {
|
||||
payload: uploadedFilename!,
|
||||
payloadType: "application/store",
|
||||
wasOffloaded: true,
|
||||
size,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an IOPacket from payload for size checking.
|
||||
*/
|
||||
#createPayloadPacket(payload: unknown, payloadType: string): IOPacket {
|
||||
if (payloadType === "application/json") {
|
||||
// Payload from SDK is already serialized as a string - use directly
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: "application/json" };
|
||||
}
|
||||
// Non-string payloads (e.g., direct API calls with objects) need serialization
|
||||
return { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: payloadType };
|
||||
}
|
||||
|
||||
// For other types, try to stringify
|
||||
try {
|
||||
return { data: JSON.stringify(payload), dataType: payloadType };
|
||||
} catch {
|
||||
return { dataType: payloadType };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { hashBucket } from "~/utils/computeBucket";
|
||||
|
||||
/** Subset of the global flags snapshot this resolver reads. */
|
||||
export type ComputeMigrationFlags = {
|
||||
computeMigrationEnabled?: boolean;
|
||||
computeMigrationFreePercentage?: number;
|
||||
computeMigrationPaidPercentage?: number;
|
||||
};
|
||||
|
||||
type MigrationDecisionInput = {
|
||||
planType: string | undefined;
|
||||
orgId: string;
|
||||
orgFeatureFlags: Record<string, unknown> | null | undefined;
|
||||
flags: ComputeMigrationFlags | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether this org should run on the compute backing. Shared by the trigger-time
|
||||
* transform and the deploy-time template decision so a migrated org always gets a
|
||||
* compute template. Precedence: per-org override (both directions) wins; otherwise
|
||||
* global enable + the plan's percentage bucket. Enterprise and unknown plans are
|
||||
* never enrolled by percentage (override only). The sole opt-out is the per-org
|
||||
* `computeMigrationEnabled: false`.
|
||||
*/
|
||||
export function isOrgMigrated({
|
||||
planType,
|
||||
orgId,
|
||||
orgFeatureFlags,
|
||||
flags,
|
||||
}: MigrationDecisionInput): boolean {
|
||||
const override = orgFeatureFlags?.["computeMigrationEnabled"];
|
||||
if (override === false) return false;
|
||||
if (override === true) return true;
|
||||
|
||||
if (!(flags?.computeMigrationEnabled ?? false)) return false;
|
||||
|
||||
const pct =
|
||||
planType === "free"
|
||||
? (flags?.computeMigrationFreePercentage ?? 0)
|
||||
: planType === "paid"
|
||||
? (flags?.computeMigrationPaidPercentage ?? 0)
|
||||
: 0; // enterprise / undefined
|
||||
|
||||
return hashBucket(orgId) < pct;
|
||||
}
|
||||
|
||||
type ResolveInput = MigrationDecisionInput & {
|
||||
baseWorkerQueue: string | undefined;
|
||||
baseEnableFastPath: boolean;
|
||||
region: string | undefined; // geo of the base queue (same whether migrated or not)
|
||||
backing: { workerQueue: string; enableFastPath: boolean } | undefined;
|
||||
envType: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Produce the target descriptor `{ workerQueue, region, enableFastPath }` for a
|
||||
* run. When the org is migrated and the region has a compute backing, the queue
|
||||
* and fast-path setting come from the MICROVM backing group; `region` is the geo
|
||||
* either way. Same-geo swap (us-east-1 -> us-east-1-next): any explicit placement
|
||||
* is a geography preference, honored by staying in-region. Applied after region
|
||||
* resolution, mirroring the scheduled-split.
|
||||
*/
|
||||
export function resolveComputeMigration({
|
||||
baseWorkerQueue,
|
||||
baseEnableFastPath,
|
||||
region,
|
||||
backing,
|
||||
envType,
|
||||
...decision
|
||||
}: ResolveInput): {
|
||||
workerQueue: string | undefined;
|
||||
region: string | undefined;
|
||||
enableFastPath: boolean;
|
||||
} {
|
||||
const passthrough = { workerQueue: baseWorkerQueue, region, enableFastPath: baseEnableFastPath };
|
||||
if (baseWorkerQueue === undefined) return passthrough;
|
||||
if (envType === "DEVELOPMENT") return passthrough;
|
||||
if (!isOrgMigrated(decision)) return passthrough;
|
||||
if (!backing) return passthrough;
|
||||
return { workerQueue: backing.workerQueue, region, enableFastPath: backing.enableFastPath };
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getMeter } from "@internal/tracing";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
baseWorkerQueue,
|
||||
matchesDisabledWorkerQueue,
|
||||
parseDisabledWorkerQueues,
|
||||
} from "./workerQueueSplit.server";
|
||||
|
||||
const meter = getMeter("run-engine-dequeue-gate");
|
||||
|
||||
const blockedDequeueCounter = meter.createCounter("run_engine.dequeue.blocked", {
|
||||
description:
|
||||
"Count of worker dequeue requests refused because the worker queue is gated off via RUN_ENGINE_DEQUEUE_DISABLED_WORKER_QUEUES",
|
||||
});
|
||||
|
||||
const disabledWorkerQueues = parseDisabledWorkerQueues(
|
||||
env.RUN_ENGINE_DEQUEUE_DISABLED_WORKER_QUEUES
|
||||
);
|
||||
|
||||
export function isWorkerQueueDequeueDisabled(workerQueue: string): boolean {
|
||||
return matchesDisabledWorkerQueue(workerQueue, disabledWorkerQueues);
|
||||
}
|
||||
|
||||
export function recordBlockedDequeue(workerQueue: string): void {
|
||||
blockedDequeueCounter.add(1, {
|
||||
worker_queue: workerQueue,
|
||||
region: baseWorkerQueue(workerQueue),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { PrismaClientOrTransaction, TaskRun } from "@trigger.dev/database";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
import type { RunEngine } from "~/v3/runEngine.server";
|
||||
import { shouldIdempotencyKeyBeCleared } from "~/v3/taskStatus";
|
||||
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
|
||||
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
|
||||
import { claimOrAwait } from "~/v3/mollifier/idempotencyClaim.server";
|
||||
import { makeResolveMollifierFlag } from "~/v3/mollifier/mollifierGate.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { runOpsLegacyPrisma, runOpsNewPrisma } from "~/db.server";
|
||||
import { isSplitEnabled } from "~/v3/runOpsMigration/splitMode.server";
|
||||
import { resolveRunIdMintKind } from "~/v3/engineVersion.server";
|
||||
import { resolveIdempotencyDedupClient } from "./idempotencyResidency.server";
|
||||
import type { TraceEventConcern, TriggerTaskRequest } from "../types";
|
||||
|
||||
// In-memory per-org mollifier-enabled check, shared with `evaluateGate`
|
||||
// (same `Organization.featureFlags` JSON, no DB read). Used to gate the
|
||||
// pre-gate claim's Redis round-trip so non-mollifier orgs don't pay it
|
||||
// during staged rollout — see the comment above the claim block in
|
||||
// handleTriggerRequest.
|
||||
const resolveOrgMollifierFlag = makeResolveMollifierFlag();
|
||||
|
||||
// Claim ownership context returned to the caller when the
|
||||
// IdempotencyKeyConcern won a pre-gate claim. Caller MUST publish the
|
||||
// winning runId on pipeline success (`publishClaim`) or release the
|
||||
// claim on failure (`releaseClaim`).
|
||||
export type ClaimedIdempotency = {
|
||||
envId: string;
|
||||
taskIdentifier: string;
|
||||
idempotencyKey: string;
|
||||
// Ownership token from `claimOrAwait`. The caller's trigger pipeline
|
||||
// MUST thread this into publishClaim/releaseClaim so the buffer's
|
||||
// compare-and-act protects the slot against a stale predecessor.
|
||||
token: string;
|
||||
};
|
||||
|
||||
export type IdempotencyKeyConcernResult =
|
||||
| { isCached: true; run: TaskRun }
|
||||
| {
|
||||
isCached: false;
|
||||
idempotencyKey?: string;
|
||||
idempotencyKeyExpiresAt?: Date;
|
||||
// Set when this trigger holds a pre-gate claim. The caller's
|
||||
// trigger pipeline MUST resolve the claim by either publishing
|
||||
// the runId on success or releasing on failure. Undefined when
|
||||
// the request has no idempotency key, when the buffer is
|
||||
// unavailable, or when the request is a triggerAndWait (claim
|
||||
// path skipped per plan doc).
|
||||
claim?: ClaimedIdempotency;
|
||||
};
|
||||
|
||||
export class IdempotencyKeyConcern {
|
||||
constructor(
|
||||
private readonly prisma: PrismaClientOrTransaction,
|
||||
private readonly engine: RunEngine,
|
||||
private readonly traceEventConcern: TraceEventConcern
|
||||
) {}
|
||||
|
||||
// Buffer-side idempotency dedup. Resolves an idempotency key against the
|
||||
// mollifier buffer when PG missed. Returns a SyntheticRun cast to
|
||||
// TaskRun so the route handler (which only reads run.id / run.friendlyId)
|
||||
// can echo the buffered run's friendlyId as a cached hit. Returns null
|
||||
// for any failure or miss — buffer outages must not 500 the trigger
|
||||
// hot path; we fail open to "no cache hit" and let the request through.
|
||||
private async findBufferedRunWithIdempotency(
|
||||
environmentId: string,
|
||||
organizationId: string,
|
||||
taskIdentifier: string,
|
||||
idempotencyKey: string
|
||||
): Promise<TaskRun | null> {
|
||||
const buffer = getMollifierBuffer();
|
||||
if (!buffer) return null;
|
||||
|
||||
let bufferedRunId: string | null;
|
||||
try {
|
||||
bufferedRunId = await buffer.lookupIdempotency({
|
||||
envId: environmentId,
|
||||
taskIdentifier,
|
||||
idempotencyKey,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("IdempotencyKeyConcern: buffer lookupIdempotency failed", {
|
||||
environmentId,
|
||||
taskIdentifier,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!bufferedRunId) return null;
|
||||
|
||||
const synthetic = await findRunByIdWithMollifierFallback({
|
||||
runId: bufferedRunId,
|
||||
environmentId,
|
||||
organizationId,
|
||||
});
|
||||
if (!synthetic) return null;
|
||||
// PG-resident path enforces idempotency-key expiry below
|
||||
// (`existingRun.idempotencyKeyExpiresAt < new Date()` clears the key
|
||||
// and lets a new run go through). The buffer path needs the same
|
||||
// check — without it a customer who passes `idempotencyKeyTTL: "2s"`
|
||||
// gets the cached buffered runId returned indefinitely, because the
|
||||
// buffer entry persists for its own (hours-long) TTL independent of
|
||||
// the customer's key TTL.
|
||||
//
|
||||
// Returning null isn't enough on its own: the trigger pipeline then
|
||||
// proceeds to `mollifyTrigger`, whose `buffer.accept` Lua dedupes by
|
||||
// `(envId, taskIdentifier, idempotencyKey)` via SETNX on the same
|
||||
// `mollifier:idempotency:*` key and would echo the stale runId as
|
||||
// `duplicate_idempotency`. Clear the buffer-side idempotency
|
||||
// binding (both the lookup and any in-flight claim) so the next
|
||||
// accept goes through as a fresh trigger. Mirrors what
|
||||
// `ResetIdempotencyKeyService` does for the explicit
|
||||
// reset-via-API path.
|
||||
if (synthetic.idempotencyKeyExpiresAt && synthetic.idempotencyKeyExpiresAt < new Date()) {
|
||||
const buffer = getMollifierBuffer();
|
||||
if (buffer) {
|
||||
try {
|
||||
await buffer.resetIdempotency({
|
||||
envId: environmentId,
|
||||
taskIdentifier,
|
||||
idempotencyKey,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("IdempotencyKeyConcern: failed to reset expired buffer idempotency", {
|
||||
envId: environmentId,
|
||||
taskIdentifier,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return synthetic as unknown as TaskRun;
|
||||
}
|
||||
|
||||
async handleTriggerRequest(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined
|
||||
): Promise<IdempotencyKeyConcernResult> {
|
||||
const idempotencyKey = request.options?.idempotencyKey ?? request.body.options?.idempotencyKey;
|
||||
const idempotencyKeyExpiresAt =
|
||||
request.options?.idempotencyKeyExpiresAt ??
|
||||
resolveIdempotencyKeyTTL(request.body.options?.idempotencyKeyTTL) ??
|
||||
new Date(Date.now() + 24 * 60 * 60 * 1000 * 30); // 30 days
|
||||
|
||||
if (!idempotencyKey) {
|
||||
return { isCached: false, idempotencyKey, idempotencyKeyExpiresAt };
|
||||
}
|
||||
|
||||
// Probe and clears must hit the DB where the would-be run will physically live.
|
||||
const dedupClient = await resolveIdempotencyDedupClient(
|
||||
{
|
||||
environmentForMint: {
|
||||
organizationId: request.environment.organizationId,
|
||||
id: request.environment.id,
|
||||
orgFeatureFlags: request.environment.organization?.featureFlags,
|
||||
},
|
||||
parentRunFriendlyId: request.body.options?.parentRunId,
|
||||
},
|
||||
{
|
||||
isSplitEnabled,
|
||||
fallbackClient: this.prisma,
|
||||
newClient: runOpsNewPrisma,
|
||||
legacyClient: runOpsLegacyPrisma,
|
||||
resolveMintKind: resolveRunIdMintKind,
|
||||
// `isMigrated` is intentionally omitted: until a child of a swept
|
||||
// legacy-id parent can be born on the new DB, the swept-marker override
|
||||
// would never change the answer, so a child routes by parent id-shape.
|
||||
}
|
||||
);
|
||||
|
||||
const existingRun = idempotencyKey
|
||||
? await runStore.findRun(
|
||||
{
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
idempotencyKey,
|
||||
taskIdentifier: request.taskId,
|
||||
},
|
||||
{
|
||||
include: {
|
||||
associatedWaitpoint: true,
|
||||
},
|
||||
},
|
||||
dedupClient
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// Buffer fallback per the mollifier-idempotency design. PG missed —
|
||||
// the same key may belong to a buffered run that hasn't materialised
|
||||
// yet. Skipped when `resumeParentOnCompletion` is set: blocking a
|
||||
// parent on a buffered child via waitpoint requires a PG row that
|
||||
// doesn't exist yet. The follow-up accept's SETNX in mollifyTrigger
|
||||
// still dedupes the trigger itself; the waitpoint just doesn't fire
|
||||
// for this rare race window.
|
||||
if (!existingRun && idempotencyKey && !request.body.options?.resumeParentOnCompletion) {
|
||||
const buffered = await this.findBufferedRunWithIdempotency(
|
||||
request.environment.id,
|
||||
request.environment.organizationId,
|
||||
request.taskId,
|
||||
idempotencyKey
|
||||
);
|
||||
if (buffered) {
|
||||
return { isCached: true, run: buffered };
|
||||
}
|
||||
}
|
||||
|
||||
if (existingRun) {
|
||||
// The idempotency key has expired
|
||||
if (existingRun.idempotencyKeyExpiresAt && existingRun.idempotencyKeyExpiresAt < new Date()) {
|
||||
logger.debug("[TriggerTaskService][call] Idempotency key has expired", {
|
||||
idempotencyKey: request.options?.idempotencyKey,
|
||||
run: existingRun,
|
||||
});
|
||||
|
||||
// Update the existing run to remove the idempotency key
|
||||
await runStore.clearIdempotencyKey(
|
||||
{ byId: { runId: existingRun.id, idempotencyKey } },
|
||||
dedupClient
|
||||
);
|
||||
|
||||
return { isCached: false, idempotencyKey, idempotencyKeyExpiresAt };
|
||||
}
|
||||
|
||||
// If the existing run failed or was expired, we clear the key and do a new run
|
||||
if (shouldIdempotencyKeyBeCleared(existingRun.status)) {
|
||||
logger.debug("[TriggerTaskService][call] Idempotency key should be cleared", {
|
||||
idempotencyKey: request.options?.idempotencyKey,
|
||||
runStatus: existingRun.status,
|
||||
runId: existingRun.id,
|
||||
});
|
||||
|
||||
// Update the existing run to remove the idempotency key
|
||||
await runStore.clearIdempotencyKey(
|
||||
{ byId: { runId: existingRun.id, idempotencyKey } },
|
||||
dedupClient
|
||||
);
|
||||
|
||||
return { isCached: false, idempotencyKey, idempotencyKeyExpiresAt };
|
||||
}
|
||||
|
||||
// We have an idempotent run, so we return it
|
||||
const parentRunId = request.body.options?.parentRunId;
|
||||
const resumeParentOnCompletion = request.body.options?.resumeParentOnCompletion;
|
||||
|
||||
//We're using `andWait` so we need to block the parent run with a waitpoint
|
||||
if (resumeParentOnCompletion && parentRunId) {
|
||||
// `parentRunId` comes from the request body and isn't re-validated
|
||||
// here, so confirm the parent run is in the caller's environment
|
||||
// before wiring a waitpoint against it.
|
||||
const parentRunInternalId = RunId.fromFriendlyId(parentRunId);
|
||||
const parentRunInCallerEnv = await runStore.findRun(
|
||||
{
|
||||
id: parentRunInternalId,
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
},
|
||||
{ select: { id: true } },
|
||||
this.prisma
|
||||
);
|
||||
if (!parentRunInCallerEnv) {
|
||||
throw new ServiceValidationError("Parent run not found in the calling environment", 404);
|
||||
}
|
||||
|
||||
// Get or create waitpoint lazily (existing run may not have one if it was standalone)
|
||||
let associatedWaitpoint = existingRun.associatedWaitpoint;
|
||||
if (!associatedWaitpoint) {
|
||||
associatedWaitpoint = await this.engine.getOrCreateRunWaitpoint({
|
||||
runId: existingRun.id,
|
||||
projectId: request.environment.projectId,
|
||||
environmentId: request.environment.id,
|
||||
});
|
||||
}
|
||||
|
||||
await this.traceEventConcern.traceIdempotentRun(
|
||||
request,
|
||||
parentStore,
|
||||
{
|
||||
existingRun,
|
||||
idempotencyKey,
|
||||
incomplete: associatedWaitpoint.status === "PENDING",
|
||||
isError: associatedWaitpoint.outputIsError,
|
||||
},
|
||||
async (event) => {
|
||||
const spanId =
|
||||
request.options?.parentAsLinkType === "replay"
|
||||
? event.spanId
|
||||
: event.traceparent?.spanId
|
||||
? `${event.traceparent.spanId}:${event.spanId}`
|
||||
: event.spanId;
|
||||
|
||||
await this.engine.blockRunWithWaitpoint({
|
||||
runId: parentRunInternalId,
|
||||
waitpoints: associatedWaitpoint!.id,
|
||||
spanIdToComplete: spanId,
|
||||
batch: request.options?.batchId
|
||||
? {
|
||||
id: request.options.batchId,
|
||||
index: request.options.batchIndex ?? 0,
|
||||
}
|
||||
: undefined,
|
||||
projectId: request.environment.projectId,
|
||||
organizationId: request.environment.organizationId,
|
||||
tx: dedupClient,
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return { isCached: true, run: existingRun };
|
||||
}
|
||||
|
||||
// Pre-gate claim — closes the PG+buffer race during gate transition.
|
||||
// All same-key triggers serialise here before evaluateGate decides
|
||||
// PG-pass-through vs mollify. Skipped for triggerAndWait
|
||||
// (resumeParentOnCompletion) — that path bypasses the gate entirely
|
||||
// and its existing PG-side dedup is sufficient.
|
||||
//
|
||||
// Gated on the same per-org mollifier flag the gate uses, and the same
|
||||
// bypass list (debounce + oneTimeUseToken): if the gate would never mollify
|
||||
// the request, there's no buffer to serialise against and PG's unique
|
||||
// constraint already deduplicates concurrent same-key races. Skipping the
|
||||
// claim's Redis SETNX keeps its RTT off the hot path for those requests
|
||||
// during staged rollout. The org-flag check is a pure in-memory read of
|
||||
// `Organization.featureFlags`, no DB query.
|
||||
const claimEligible =
|
||||
!request.body.options?.resumeParentOnCompletion &&
|
||||
!request.body.options?.debounce &&
|
||||
!request.options?.oneTimeUseToken &&
|
||||
(await resolveOrgMollifierFlag({
|
||||
envId: request.environment.id,
|
||||
orgId: request.environment.organizationId,
|
||||
taskId: request.taskId,
|
||||
orgFeatureFlags:
|
||||
(request.environment.organization?.featureFlags as
|
||||
| Record<string, unknown>
|
||||
| null
|
||||
| undefined) ?? null,
|
||||
}));
|
||||
if (claimEligible) {
|
||||
const ttlSeconds = Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
env.TRIGGER_MOLLIFIER_CLAIM_TTL_SECONDS,
|
||||
Math.ceil((idempotencyKeyExpiresAt.getTime() - Date.now()) / 1000)
|
||||
)
|
||||
);
|
||||
const outcome = await claimOrAwait({
|
||||
envId: request.environment.id,
|
||||
taskIdentifier: request.taskId,
|
||||
idempotencyKey,
|
||||
ttlSeconds,
|
||||
safetyNetMs: env.TRIGGER_MOLLIFIER_CLAIM_WAIT_MS,
|
||||
pollStepMs: env.TRIGGER_MOLLIFIER_CLAIM_POLL_MS,
|
||||
});
|
||||
if (outcome.kind === "resolved") {
|
||||
// Another concurrent trigger committed first. Re-resolve via the
|
||||
// existing checks: writer-side PG findFirst first (defeats
|
||||
// replica lag), then buffer fallback for the buffered case.
|
||||
const writerRun = await runStore.findRun(
|
||||
{
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
idempotencyKey,
|
||||
taskIdentifier: request.taskId,
|
||||
},
|
||||
{ include: { associatedWaitpoint: true } },
|
||||
dedupClient
|
||||
);
|
||||
if (writerRun) {
|
||||
return { isCached: true, run: writerRun };
|
||||
}
|
||||
const buffered = await this.findBufferedRunWithIdempotency(
|
||||
request.environment.id,
|
||||
request.environment.organizationId,
|
||||
request.taskId,
|
||||
idempotencyKey
|
||||
);
|
||||
if (buffered) {
|
||||
return { isCached: true, run: buffered };
|
||||
}
|
||||
// Claim resolved to a runId nothing can find — the run was genuinely
|
||||
// lost (claimant errored after publish, or both the PG row and buffer
|
||||
// entry TTL'd out). Terminal, not transient, so falling through to a
|
||||
// fresh trigger is the correct recovery.
|
||||
//
|
||||
// Falling through claimless doesn't duplicate runs: concurrent
|
||||
// fall-throughs converge on one run via the same dedup backstops the
|
||||
// claim layer relies on — PG's unique constraint on the idempotency key
|
||||
// (pass-through path) and `accept`'s SETNX (mollify path). Once the
|
||||
// first commits, later callers find it via the writer-PG / buffer
|
||||
// lookups above despite the stale `resolved:` slot (cleared by its ~30s
|
||||
// TTL). Residual cost is a few deduped trigger attempts, not dup runs.
|
||||
logger.warn("idempotency claim resolved but runId not findable", {
|
||||
envId: request.environment.id,
|
||||
taskIdentifier: request.taskId,
|
||||
claimedRunId: outcome.runId,
|
||||
});
|
||||
}
|
||||
if (outcome.kind === "timed_out") {
|
||||
throw new ServiceValidationError("Idempotency claim resolution timed out", 503);
|
||||
}
|
||||
if (outcome.kind === "claimed") {
|
||||
// Caller MUST publish/release. Signalled via the result's
|
||||
// `claim` field, including the ownership token so the buffer
|
||||
// can compare-and-act on the slot we now own.
|
||||
return {
|
||||
isCached: false,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt,
|
||||
claim: {
|
||||
envId: request.environment.id,
|
||||
taskIdentifier: request.taskId,
|
||||
idempotencyKey,
|
||||
token: outcome.token,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { isCached: false, idempotencyKey, idempotencyKeyExpiresAt };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
resolveIdempotencyDedupClient,
|
||||
type ResolveIdempotencyClientDeps,
|
||||
} from "./idempotencyResidency.server";
|
||||
|
||||
// Distinct sentinel objects so we can assert WHICH client was selected by reference.
|
||||
const FALLBACK = { __tag: "fallback" } as never;
|
||||
const NEW_CLIENT = { __tag: "new" } as never;
|
||||
const LEGACY_CLIENT = { __tag: "legacy" } as never;
|
||||
|
||||
function makeDeps(over: Partial<ResolveIdempotencyClientDeps>): ResolveIdempotencyClientDeps {
|
||||
return {
|
||||
isSplitEnabled: async () => true,
|
||||
fallbackClient: FALLBACK,
|
||||
newClient: NEW_CLIENT,
|
||||
legacyClient: LEGACY_CLIENT,
|
||||
resolveMintKind: async () => "runOpsId",
|
||||
classify: (id) => {
|
||||
if (id.length === 26 && id[25] === "1") return "NEW";
|
||||
if (id.length === 25) return "LEGACY";
|
||||
throw new Error(`unclassifiable: ${id.length}`);
|
||||
},
|
||||
isMigrated: undefined,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const env = { organizationId: "org_1", id: "env_1", orgFeatureFlags: {} };
|
||||
|
||||
describe("resolveIdempotencyDedupClient", () => {
|
||||
it("returns the fallback client unchanged when split is disabled", async () => {
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: undefined },
|
||||
makeDeps({ isSplitEnabled: async () => false })
|
||||
);
|
||||
expect(client).toBe(FALLBACK);
|
||||
});
|
||||
|
||||
it("routes a root run to the NEW client when the env mints run-ops ids", async () => {
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: undefined },
|
||||
makeDeps({ resolveMintKind: async () => "runOpsId" })
|
||||
);
|
||||
expect(client).toBe(NEW_CLIENT);
|
||||
});
|
||||
|
||||
it("routes a root run to the LEGACY client when the env mints cuid", async () => {
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: undefined },
|
||||
makeDeps({ resolveMintKind: async () => "cuid" })
|
||||
);
|
||||
expect(client).toBe(LEGACY_CLIENT);
|
||||
});
|
||||
|
||||
it("routes a child to the NEW client when the run-ops parent is NEW-resident", async () => {
|
||||
const runOpsParent = RunId.toFriendlyId("a".repeat(24) + "01");
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: runOpsParent },
|
||||
makeDeps({ resolveMintKind: async () => "cuid" }) // mint flag must NOT win for a child
|
||||
);
|
||||
expect(client).toBe(NEW_CLIENT);
|
||||
});
|
||||
|
||||
it("routes a child to the LEGACY client when the cuid parent is LEGACY-resident", async () => {
|
||||
const cuidParent = RunId.toFriendlyId("b".repeat(25));
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: cuidParent },
|
||||
makeDeps({ resolveMintKind: async () => "runOpsId" }) // mint flag must NOT win for a child
|
||||
);
|
||||
expect(client).toBe(LEGACY_CLIENT);
|
||||
});
|
||||
|
||||
it("routes a swept (migrated) cuid-parent child to the NEW client", async () => {
|
||||
const cuidParent = RunId.toFriendlyId("c".repeat(25));
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: cuidParent },
|
||||
makeDeps({ isMigrated: async () => true })
|
||||
);
|
||||
expect(client).toBe(NEW_CLIENT);
|
||||
});
|
||||
|
||||
it("routes a non-migrated cuid-parent child to the LEGACY client even when isMigrated is provided", async () => {
|
||||
const cuidParent = RunId.toFriendlyId("d".repeat(25));
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: cuidParent },
|
||||
makeDeps({ isMigrated: async () => false })
|
||||
);
|
||||
expect(client).toBe(LEGACY_CLIENT);
|
||||
});
|
||||
|
||||
it("falls back to the fallback client when a present parent id is unclassifiable", async () => {
|
||||
const client = await resolveIdempotencyDedupClient(
|
||||
{ environmentForMint: env, parentRunFriendlyId: "run_not-a-valid-length" },
|
||||
makeDeps({})
|
||||
);
|
||||
expect(client).toBe(FALLBACK);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ownerEngine, RunId, type Residency } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
|
||||
type MintKind = "cuid" | "runOpsId";
|
||||
|
||||
export type ResolveIdempotencyClientDeps = {
|
||||
isSplitEnabled: () => Promise<boolean>;
|
||||
fallbackClient: PrismaClientOrTransaction;
|
||||
newClient: PrismaClientOrTransaction;
|
||||
legacyClient: PrismaClientOrTransaction;
|
||||
resolveMintKind: (environment: {
|
||||
organizationId: string;
|
||||
id: string;
|
||||
orgFeatureFlags?: unknown;
|
||||
}) => Promise<MintKind>;
|
||||
classify?: (id: string) => Residency;
|
||||
isMigrated?: (id: string) => Promise<boolean>;
|
||||
};
|
||||
|
||||
export async function resolveIdempotencyDedupClient(
|
||||
args: {
|
||||
environmentForMint: { organizationId: string; id: string; orgFeatureFlags?: unknown };
|
||||
parentRunFriendlyId: string | undefined;
|
||||
},
|
||||
deps: ResolveIdempotencyClientDeps
|
||||
): Promise<PrismaClientOrTransaction> {
|
||||
if (!(await deps.isSplitEnabled())) {
|
||||
return deps.fallbackClient;
|
||||
}
|
||||
|
||||
const classify = deps.classify ?? ownerEngine;
|
||||
const clientFor = (residency: Residency): PrismaClientOrTransaction =>
|
||||
residency === "NEW" ? deps.newClient : deps.legacyClient;
|
||||
|
||||
if (args.parentRunFriendlyId) {
|
||||
let parentInternalId: string;
|
||||
try {
|
||||
parentInternalId = RunId.fromFriendlyId(args.parentRunFriendlyId);
|
||||
} catch {
|
||||
return deps.fallbackClient;
|
||||
}
|
||||
let residency: Residency;
|
||||
try {
|
||||
residency = classify(parentInternalId);
|
||||
} catch {
|
||||
return deps.fallbackClient;
|
||||
}
|
||||
if (residency === "LEGACY" && deps.isMigrated && (await deps.isMigrated(parentInternalId))) {
|
||||
return deps.newClient;
|
||||
}
|
||||
return clientFor(residency);
|
||||
}
|
||||
|
||||
const kind = await deps.resolveMintKind(args.environmentForMint);
|
||||
return clientFor(kind === "runOpsId" ? "NEW" : "LEGACY");
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { IOPacket } from "@trigger.dev/core/v3";
|
||||
import { packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
||||
import type { PayloadProcessor, TriggerTaskRequest } from "../types";
|
||||
import { env } from "~/env.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { uploadPacketToObjectStore } from "~/v3/objectStore.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
export class DefaultPayloadProcessor implements PayloadProcessor {
|
||||
async process(request: TriggerTaskRequest): Promise<IOPacket> {
|
||||
return await startActiveSpan("handlePayloadPacket()", async (span) => {
|
||||
const payload = request.body.payload;
|
||||
const payloadType = request.body.options?.payloadType ?? "application/json";
|
||||
|
||||
const packet = this.#createPayloadPacket(payload, payloadType);
|
||||
|
||||
if (!packet.data) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const { needsOffloading, size } = packetRequiresOffloading(
|
||||
packet,
|
||||
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
|
||||
);
|
||||
|
||||
span.setAttribute("needsOffloading", needsOffloading);
|
||||
// When the caller already offloaded the payload (payloadType "application/store"), the
|
||||
// packet here is just the small object-store reference, so `size` measures the reference,
|
||||
// not the payload. Prefer the caller-reported pre-offload size when it's provided so the
|
||||
// span reflects the real payload size. For inline payloads the two agree.
|
||||
span.setAttribute("size", request.body.options?.payloadSize ?? size);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const filename = `${request.friendlyId}/payload.json`;
|
||||
|
||||
const [uploadError, uploadedFilename] = await tryCatch(
|
||||
uploadPacketToObjectStore(
|
||||
filename,
|
||||
packet.data,
|
||||
packet.dataType,
|
||||
request.environment,
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL
|
||||
)
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
throw new ServiceValidationError("Failed to upload large payload to object store", 500); // This is retryable
|
||||
}
|
||||
|
||||
return {
|
||||
data: uploadedFilename!,
|
||||
dataType: "application/store",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
#createPayloadPacket(payload: any, payloadType: string): IOPacket {
|
||||
if (payloadType === "application/json") {
|
||||
return { data: JSON.stringify(payload), dataType: "application/json" };
|
||||
}
|
||||
|
||||
if (typeof payload === "string") {
|
||||
return { data: payload, dataType: payloadType };
|
||||
}
|
||||
|
||||
return { dataType: payloadType };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,532 @@
|
||||
import { sanitizeQueueName } 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 { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import type {
|
||||
LockedBackgroundWorker,
|
||||
QueueManager,
|
||||
QueueProperties,
|
||||
QueueValidationResult,
|
||||
TriggerTaskRequest,
|
||||
} from "../types";
|
||||
import { WorkerGroupService } from "~/v3/services/worker/workerGroupService.server";
|
||||
import type { RunEngine } from "~/v3/runEngine.server";
|
||||
import { env } from "~/env.server";
|
||||
import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
import { isInfrastructureError } from "~/utils/prismaErrors";
|
||||
import {
|
||||
createCache,
|
||||
createLRUMemoryStore,
|
||||
DefaultStatefulContext,
|
||||
Namespace,
|
||||
} from "@internal/cache";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import type { TaskMetadataCache, TaskMetadataEntry } from "~/services/taskMetadataCache.server";
|
||||
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
||||
import {
|
||||
recordTaskMetaResolve,
|
||||
type TaskMetaResolveSource,
|
||||
} from "~/services/taskMetadataCacheTelemetry.server";
|
||||
|
||||
// LRU cache for environment queue sizes to reduce Redis calls
|
||||
const queueSizeCache = singleton("queueSizeCache", () => {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = createLRUMemoryStore(env.QUEUE_SIZE_CACHE_MAX_SIZE, "queue-size-cache");
|
||||
|
||||
return createCache({
|
||||
queueSize: new Namespace<number>(ctx, {
|
||||
stores: [memory],
|
||||
fresh: env.QUEUE_SIZE_CACHE_TTL_MS,
|
||||
stale: env.QUEUE_SIZE_CACHE_TTL_MS + 1000,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Extract the queue name from a queue option that may be:
|
||||
* - An object with a string `name` property: { name: "queue-name" }
|
||||
* - A double-wrapped object (bug case): { name: { name: "queue-name", ... } }
|
||||
*
|
||||
* This handles the case where the SDK accidentally double-wraps the queue
|
||||
* option when it's already an object with a name property.
|
||||
*/
|
||||
function extractQueueName(queue: { name?: unknown } | undefined): string | undefined {
|
||||
if (!queue?.name) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Normal case: queue.name is a string
|
||||
if (typeof queue.name === "string") {
|
||||
return queue.name;
|
||||
}
|
||||
|
||||
// Double-wrapped case: queue.name is an object with its own name property
|
||||
if (typeof queue.name === "object" && queue.name !== null && "name" in queue.name) {
|
||||
const innerName = (queue.name as { name: unknown }).name;
|
||||
if (typeof innerName === "string") {
|
||||
return innerName;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export class DefaultQueueManager implements QueueManager {
|
||||
private readonly replicaPrisma: PrismaClientOrTransaction;
|
||||
private readonly taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClientOrTransaction,
|
||||
private readonly engine: RunEngine,
|
||||
replicaPrisma?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
this.replicaPrisma = replicaPrisma ?? prisma;
|
||||
this.taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
async resolveQueueProperties(
|
||||
request: TriggerTaskRequest,
|
||||
lockedBackgroundWorker?: LockedBackgroundWorker
|
||||
): Promise<QueueProperties> {
|
||||
let queueName: string;
|
||||
let lockedQueueId: string | undefined;
|
||||
let taskTtl: string | null | undefined;
|
||||
let taskKind: string | undefined;
|
||||
|
||||
// Determine queue name based on lockToVersion and provided options
|
||||
if (lockedBackgroundWorker) {
|
||||
// Task is locked to a specific worker version
|
||||
const specifiedQueueName = extractQueueName(request.body.options?.queue);
|
||||
|
||||
if (specifiedQueueName) {
|
||||
// A specific queue name is provided, validate it exists for the locked worker.
|
||||
// Pre-existing query — not cached because TaskQueue rows can be added or
|
||||
// removed independently of BackgroundWorkerTask, and a stale "queue exists"
|
||||
// claim would silently route to the wrong queue.
|
||||
const specifiedQueue = await this.prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
name: specifiedQueueName,
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
workers: { some: { id: lockedBackgroundWorker.id } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!specifiedQueue) {
|
||||
throw new ServiceValidationError(
|
||||
`Specified queue '${specifiedQueueName}' not found or not associated with locked version '${
|
||||
lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
|
||||
// Use the validated queue name directly
|
||||
queueName = specifiedQueue.name;
|
||||
lockedQueueId = specifiedQueue.id;
|
||||
|
||||
// Pull `triggerSource` (for `taskKind` annotation) and `ttl` from cache.
|
||||
// On cache hit this is 0 PG queries; on miss the helper falls back to
|
||||
// a BackgroundWorkerTask lookup and back-fills the cache.
|
||||
//
|
||||
// If the task slug isn't on this locked worker version, we tolerate
|
||||
// the missing row and fall through with `taskKind = undefined`
|
||||
// (coalesced to "STANDARD" downstream) and `taskTtl = undefined`.
|
||||
// This matches main's pre-PR behavior — the no-override branch below
|
||||
// still throws because there's no queue to route to in that case,
|
||||
// but here the caller already named the queue.
|
||||
const lockedMeta = await this.resolveLockedTaskMetadata(
|
||||
lockedBackgroundWorker.id,
|
||||
request.environment.id,
|
||||
request.taskId
|
||||
);
|
||||
|
||||
if (request.body.options?.ttl === undefined) {
|
||||
taskTtl = lockedMeta?.ttl ?? undefined;
|
||||
}
|
||||
taskKind = lockedMeta?.triggerSource;
|
||||
} else {
|
||||
// No queue override - resolve default queue + TTL + triggerSource via cache,
|
||||
// falling back to a single BackgroundWorkerTask lookup on miss.
|
||||
const lockedMeta = await this.resolveLockedTaskMetadata(
|
||||
lockedBackgroundWorker.id,
|
||||
request.environment.id,
|
||||
request.taskId
|
||||
);
|
||||
|
||||
if (!lockedMeta) {
|
||||
throw new ServiceValidationError(
|
||||
`Task '${request.taskId}' not found on locked version '${
|
||||
lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
|
||||
taskTtl = lockedMeta.ttl;
|
||||
|
||||
if (!lockedMeta.queueName) {
|
||||
// This case should ideally be prevented by earlier checks or schema constraints,
|
||||
// but handle it defensively.
|
||||
logger.error("Task found on locked version, but has no associated queue record", {
|
||||
taskId: request.taskId,
|
||||
workerId: lockedBackgroundWorker.id,
|
||||
version: lockedBackgroundWorker.version,
|
||||
});
|
||||
throw new ServiceValidationError(
|
||||
`Default queue configuration for task '${request.taskId}' missing on locked version '${
|
||||
lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
|
||||
// Use the task's default queue name
|
||||
queueName = lockedMeta.queueName;
|
||||
lockedQueueId = lockedMeta.queueId ?? undefined;
|
||||
taskKind = lockedMeta.triggerSource;
|
||||
}
|
||||
} else {
|
||||
// Task is not locked to a specific version, use regular logic
|
||||
if (request.body.options?.lockToVersion) {
|
||||
// This should only happen if the findFirst failed, indicating the version doesn't exist
|
||||
throw new ServiceValidationError(
|
||||
`Task locked to version '${request.body.options.lockToVersion}', but no worker found with that version.`
|
||||
);
|
||||
}
|
||||
|
||||
// Get queue name using the helper for non-locked case (handles provided name or finds default)
|
||||
const taskInfo = await this.getTaskQueueInfo(request);
|
||||
queueName = taskInfo.queueName;
|
||||
taskTtl = taskInfo.taskTtl;
|
||||
taskKind = taskInfo.taskKind;
|
||||
}
|
||||
|
||||
// Sanitize the final determined queue name once
|
||||
const sanitizedQueueName = sanitizeQueueName(queueName);
|
||||
|
||||
// Check that the queuename is not an empty string
|
||||
if (!sanitizedQueueName) {
|
||||
queueName = sanitizeQueueName(`task/${request.taskId}`); // Fallback if sanitization results in empty
|
||||
} else {
|
||||
queueName = sanitizedQueueName;
|
||||
}
|
||||
|
||||
return {
|
||||
queueName,
|
||||
lockedQueueId,
|
||||
taskTtl,
|
||||
taskKind,
|
||||
};
|
||||
}
|
||||
|
||||
private async getTaskQueueInfo(
|
||||
request: TriggerTaskRequest
|
||||
): Promise<{ queueName: string; taskTtl?: string | null; taskKind?: string | undefined }> {
|
||||
const { taskId, environment, body } = request;
|
||||
const { queue } = body.options ?? {};
|
||||
|
||||
// Use extractQueueName to handle double-wrapped queue objects
|
||||
const overriddenQueueName = extractQueueName(queue);
|
||||
|
||||
const defaultQueueName = `task/${taskId}`;
|
||||
|
||||
// Resolve the current worker's task metadata via cache (HGET on warm path,
|
||||
// BackgroundWorkerTask findFirst + cache back-fill on miss). When this hits,
|
||||
// both the queue-override + TTL caller and the default-queue caller satisfy
|
||||
// their full result without any database query.
|
||||
const meta = await this.resolveCurrentTaskMetadata(environment, taskId);
|
||||
|
||||
if (overriddenQueueName) {
|
||||
// Caller already named the queue. We only need triggerSource (for taskKind)
|
||||
// and ttl (for the call site to coalesce against body.options.ttl).
|
||||
return {
|
||||
queueName: overriddenQueueName,
|
||||
taskTtl: meta?.ttl ?? undefined,
|
||||
taskKind: meta?.triggerSource,
|
||||
};
|
||||
}
|
||||
|
||||
if (!meta) {
|
||||
logger.debug("Failed to get queue name: No worker or task found", {
|
||||
taskId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return { queueName: defaultQueueName, taskTtl: undefined };
|
||||
}
|
||||
|
||||
if (!meta.queueName) {
|
||||
logger.debug("Failed to get queue name: No queue found", {
|
||||
taskId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
return { queueName: defaultQueueName, taskTtl: meta.ttl, taskKind: meta.triggerSource };
|
||||
}
|
||||
|
||||
return { queueName: meta.queueName, taskTtl: meta.ttl, taskKind: meta.triggerSource };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task metadata for a locked-version trigger. Reads from the
|
||||
* `task-meta:by-worker:{workerId}` Redis hash; falls back to a single
|
||||
* BackgroundWorkerTask findFirst on miss and back-fills the cache.
|
||||
*
|
||||
* Returns null when no BackgroundWorkerTask row exists.
|
||||
*/
|
||||
private async resolveLockedTaskMetadata(
|
||||
workerId: string,
|
||||
environmentId: string,
|
||||
slug: string
|
||||
): Promise<TaskMetadataEntry | null> {
|
||||
const cached = await this.taskMetaCache.getByWorker(workerId, slug);
|
||||
if (cached) {
|
||||
recordTaskMetaResolve("locked", "cache");
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cache miss. Read the row from the replica first; if the replica comes
|
||||
// back empty, re-check the writer before concluding the task is missing.
|
||||
// The locked worker itself was just resolved on the writer (see
|
||||
// triggerTask.server.ts), so a replica that returns no row here is stale,
|
||||
// not authoritative. Trusting a stale-replica negative throws a
|
||||
// non-retryable "not found on locked version" for a task that is in fact
|
||||
// registered. The writer read only runs on this rare miss-then-empty path,
|
||||
// never on the hot path.
|
||||
let row = await this.findLockedTaskRow(this.replicaPrisma, workerId, environmentId, slug);
|
||||
let source: TaskMetaResolveSource = "replica";
|
||||
|
||||
if (!row && this.replicaPrisma !== this.prisma) {
|
||||
row = await this.findLockedTaskRow(this.prisma, workerId, environmentId, slug);
|
||||
|
||||
if (row) {
|
||||
source = "writer";
|
||||
logger.warn("Locked task metadata missing on replica but found on writer", {
|
||||
workerId,
|
||||
environmentId,
|
||||
slug,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
recordTaskMetaResolve("locked", "miss");
|
||||
return null;
|
||||
}
|
||||
|
||||
recordTaskMetaResolve("locked", source);
|
||||
|
||||
const entry: TaskMetadataEntry = {
|
||||
slug,
|
||||
ttl: row.ttl,
|
||||
triggerSource: row.triggerSource,
|
||||
queueId: row.queue?.id ?? null,
|
||||
queueName: row.queue?.name ?? "",
|
||||
};
|
||||
|
||||
// Fire-and-forget back-fill — `setByWorker` upserts the single field and
|
||||
// refreshes the hash TTL. Errors are logged inside the cache and swallowed.
|
||||
void this.taskMetaCache.setByWorker(workerId, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private findLockedTaskRow(
|
||||
client: PrismaClientOrTransaction,
|
||||
workerId: string,
|
||||
environmentId: string,
|
||||
slug: string
|
||||
) {
|
||||
return client.backgroundWorkerTask.findFirst({
|
||||
where: { workerId, runtimeEnvironmentId: environmentId, slug },
|
||||
select: {
|
||||
ttl: true,
|
||||
triggerSource: true,
|
||||
queue: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task metadata for a non-locked trigger. Reads from the
|
||||
* `task-meta:env:{envId}` Redis hash; falls back to
|
||||
* findCurrentWorkerFromEnvironment + a single BackgroundWorkerTask findFirst
|
||||
* on miss and back-fills both keyspaces.
|
||||
*
|
||||
* Returns null when no current worker or task can be resolved.
|
||||
*/
|
||||
private async resolveCurrentTaskMetadata(
|
||||
environment: AuthenticatedEnvironment,
|
||||
slug: string
|
||||
): Promise<TaskMetadataEntry | null> {
|
||||
const cached = await this.taskMetaCache.getCurrent(environment.id, slug);
|
||||
if (cached) {
|
||||
recordTaskMetaResolve("current", "cache");
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Cold cache: discover the current worker for the env. Replica is fine —
|
||||
// the adjacent BackgroundWorkerTask lookup below uses `replicaPrisma` too
|
||||
// (replica lag for "just deployed" is bounded the same way for both
|
||||
// queries; reading from the writer here would only widen the window).
|
||||
const worker = await findCurrentWorkerFromEnvironment(environment, this.replicaPrisma);
|
||||
if (!worker) {
|
||||
recordTaskMetaResolve("current", "miss");
|
||||
return null;
|
||||
}
|
||||
|
||||
const row = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: { workerId: worker.id, runtimeEnvironmentId: environment.id, slug },
|
||||
select: {
|
||||
ttl: true,
|
||||
triggerSource: true,
|
||||
queue: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
recordTaskMetaResolve("current", "miss");
|
||||
return null;
|
||||
}
|
||||
|
||||
recordTaskMetaResolve("current", "replica");
|
||||
|
||||
const entry: TaskMetadataEntry = {
|
||||
slug,
|
||||
ttl: row.ttl,
|
||||
triggerSource: row.triggerSource,
|
||||
queueId: row.queue?.id ?? null,
|
||||
queueName: row.queue?.name ?? "",
|
||||
};
|
||||
|
||||
// Fire-and-forget back-fill — atomically upserts the slug into both
|
||||
// keyspaces so a subsequent locked-or-not trigger hits the cache. The
|
||||
// env-keyspace TTL is preserved (promotion owns it); the by-worker TTL
|
||||
// is refreshed (sliding window keeps active workers warm).
|
||||
void this.taskMetaCache.setByCurrentWorker(environment.id, worker.id, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async validateQueueLimits(
|
||||
environment: AuthenticatedEnvironment,
|
||||
queueName: string,
|
||||
itemsToAdd?: number
|
||||
): Promise<QueueValidationResult> {
|
||||
const queueSizeGuard = await guardQueueSizeLimitsForQueue(
|
||||
this.engine,
|
||||
environment,
|
||||
queueName,
|
||||
itemsToAdd
|
||||
);
|
||||
|
||||
logger.debug("Queue size guard result", {
|
||||
queueSizeGuard,
|
||||
queueName,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
organization: environment.organization,
|
||||
project: environment.project,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
ok: queueSizeGuard.isWithinLimits,
|
||||
maximumSize: queueSizeGuard.maximumSize ?? 0,
|
||||
queueSize: queueSizeGuard.queueSize ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkerQueue(
|
||||
environment: AuthenticatedEnvironment,
|
||||
regionOverride?: string
|
||||
): Promise<{ masterQueue: string; enableFastPath: boolean } | undefined> {
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
return { masterQueue: environment.id, enableFastPath: true };
|
||||
}
|
||||
|
||||
const workerGroupService = new WorkerGroupService({
|
||||
prisma: this.prisma,
|
||||
engine: this.engine,
|
||||
});
|
||||
|
||||
const [error, workerGroup] = await tryCatch(
|
||||
workerGroupService.getDefaultWorkerGroupForProject({
|
||||
projectId: environment.projectId,
|
||||
regionOverride,
|
||||
})
|
||||
);
|
||||
|
||||
if (error) {
|
||||
// getDefaultWorkerGroupForProject queries the writer DB. A Prisma
|
||||
// infrastructure error (e.g. P1001 "Can't reach database server", whose
|
||||
// message carries the DB hostname) must NOT be promoted into a
|
||||
// client-facing ServiceValidationError: that leaks internal infra detail
|
||||
// to the API client (the SDK echoes it into the run view) and
|
||||
// mis-classifies a transient outage as a non-retryable 422. Let it
|
||||
// propagate to the route's generic 500 handler (scrubbed + retryable);
|
||||
// only wrap genuine domain failures.
|
||||
if (isInfrastructureError(error)) {
|
||||
throw error;
|
||||
}
|
||||
throw new ServiceValidationError(error.message);
|
||||
}
|
||||
|
||||
if (!workerGroup) {
|
||||
throw new ServiceValidationError("No worker group found");
|
||||
}
|
||||
|
||||
return {
|
||||
masterQueue: workerGroup.masterQueue,
|
||||
enableFastPath: workerGroup.enableFastPath,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaximumSizeForEnvironment(
|
||||
environment: AuthenticatedEnvironment
|
||||
): number | undefined {
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
return environment.organization.maximumDevQueueSize ?? env.MAXIMUM_DEV_QUEUE_SIZE;
|
||||
} else {
|
||||
return environment.organization.maximumDeployedQueueSize ?? env.MAXIMUM_DEPLOYED_QUEUE_SIZE;
|
||||
}
|
||||
}
|
||||
|
||||
async function guardQueueSizeLimitsForQueue(
|
||||
engine: RunEngine,
|
||||
environment: AuthenticatedEnvironment,
|
||||
queueName: string,
|
||||
itemsToAdd: number = 1
|
||||
) {
|
||||
const maximumSize = getMaximumSizeForEnvironment(environment);
|
||||
|
||||
if (typeof maximumSize === "undefined") {
|
||||
return { isWithinLimits: true };
|
||||
}
|
||||
|
||||
const queueSize = await getCachedQueueSize(engine, environment, queueName);
|
||||
const projectedSize = queueSize + itemsToAdd;
|
||||
|
||||
return {
|
||||
isWithinLimits: projectedSize <= maximumSize,
|
||||
maximumSize,
|
||||
queueSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCachedQueueSize(
|
||||
engine: RunEngine,
|
||||
environment: AuthenticatedEnvironment,
|
||||
queueName: string
|
||||
): Promise<number> {
|
||||
if (!env.QUEUE_SIZE_CACHE_ENABLED) {
|
||||
return engine.lengthOfQueue(environment, queueName);
|
||||
}
|
||||
|
||||
const cacheKey = `${environment.id}:${queueName}`;
|
||||
const result = await queueSizeCache.queueSize.swr(cacheKey, async () => {
|
||||
return engine.lengthOfQueue(environment, queueName);
|
||||
});
|
||||
|
||||
return result.val ?? 0;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { PrismaReplicaClient } from "~/db.server";
|
||||
import {
|
||||
$replica as defaultLegacyReplica,
|
||||
runOpsNewPrisma as defaultNewPrimary,
|
||||
runOpsNewReplica as defaultNewClient,
|
||||
runOpsSplitReadEnabled as defaultSplitReadEnabled,
|
||||
} from "~/db.server";
|
||||
import { readThroughRun } from "~/v3/runOpsMigration/readThrough.server";
|
||||
|
||||
type ResolveWaitpointDeps = {
|
||||
newClient?: PrismaReplicaClient;
|
||||
legacyReplica?: PrismaReplicaClient;
|
||||
newPrimary?: PrismaReplicaClient;
|
||||
splitEnabled?: boolean;
|
||||
isPastRetention?: (id: string) => boolean;
|
||||
};
|
||||
|
||||
// Safe defaults matching the deps `complete`/`callback` pass, so a bare caller still fans
|
||||
// out to the dedicated run-ops replica (NEW-resident waitpoints) before control-plane.
|
||||
export type ResolveWaitpointReadThroughDefaults = {
|
||||
newClient: PrismaReplicaClient;
|
||||
legacyReplica: PrismaReplicaClient;
|
||||
newPrimary: PrismaReplicaClient;
|
||||
splitEnabled: boolean;
|
||||
};
|
||||
|
||||
const productionDefaults: ResolveWaitpointReadThroughDefaults = {
|
||||
newClient: defaultNewClient,
|
||||
legacyReplica: defaultLegacyReplica,
|
||||
newPrimary: defaultNewPrimary as unknown as PrismaReplicaClient,
|
||||
splitEnabled: defaultSplitReadEnabled,
|
||||
};
|
||||
|
||||
export async function resolveWaitpointThroughReadThrough<T>(opts: {
|
||||
waitpointId: string;
|
||||
environmentId: string;
|
||||
read: (client: PrismaReplicaClient) => Promise<T | null>;
|
||||
deps?: ResolveWaitpointDeps;
|
||||
defaults?: ResolveWaitpointReadThroughDefaults;
|
||||
}): Promise<T | null> {
|
||||
const defaults = opts.defaults ?? productionDefaults;
|
||||
|
||||
const splitEnabled = opts.deps?.splitEnabled ?? defaults.splitEnabled;
|
||||
|
||||
const result = await readThroughRun({
|
||||
runId: opts.waitpointId,
|
||||
environmentId: opts.environmentId,
|
||||
readNew: (client) => opts.read(client),
|
||||
readLegacy: (replica) => opts.read(replica),
|
||||
deps: {
|
||||
splitEnabled,
|
||||
newClient: opts.deps?.newClient ?? defaults.newClient,
|
||||
legacyReplica: opts.deps?.legacyReplica ?? defaults.legacyReplica,
|
||||
isPastRetention: opts.deps?.isPastRetention,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.source === "new" || result.source === "legacy-replica") {
|
||||
return result.value;
|
||||
}
|
||||
// past-retention is an intentional not-found: the token is gone.
|
||||
if (result.source === "past-retention") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read-your-writes fallback for a token completed immediately after mint, before it replicated:
|
||||
// re-read from the run-ops PRIMARY only. We deliberately never read the control-plane/legacy
|
||||
// primary here (that is the load the replica-only read-through exists to shed), so a legacy-resident
|
||||
// token that misses its replica stays a miss and the caller retries, rather than adding primary load.
|
||||
const fromNewPrimary = await opts.read(opts.deps?.newPrimary ?? defaults.newPrimary);
|
||||
if (fromNewPrimary != null) {
|
||||
return fromNewPrimary;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { SemanticInternalAttributes } from "@trigger.dev/core/v3/semanticInternalAttributes";
|
||||
import type { TaskRun } from "@trigger.dev/database";
|
||||
import type { IEventRepository } from "~/v3/eventRepository/eventRepository.types";
|
||||
import { getEventRepository } from "~/v3/eventRepository/index.server";
|
||||
import type { TracedEventSpan, TraceEventConcern, TriggerTaskRequest } from "../types";
|
||||
|
||||
export class DefaultTraceEventsConcern implements TraceEventConcern {
|
||||
async #getEventRepository(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined
|
||||
): Promise<{ repository: IEventRepository; store: string }> {
|
||||
return await getEventRepository(
|
||||
request.environment.organization.id,
|
||||
request.environment.organization.featureFlags as Record<string, unknown>,
|
||||
parentStore
|
||||
);
|
||||
}
|
||||
|
||||
async traceRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const { repository, store } = await this.#getEventRepository(request, parentStore);
|
||||
|
||||
return await repository.traceEvent(
|
||||
request.taskId,
|
||||
{
|
||||
context: request.options?.traceContext,
|
||||
spanParentAsLink: request.options?.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: request.environment,
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {},
|
||||
style: {
|
||||
icon: request.options?.customIcon ?? "task",
|
||||
},
|
||||
},
|
||||
incomplete: true,
|
||||
immediate: true,
|
||||
startTime: request.options?.overrideCreatedAt
|
||||
? BigInt(request.options.overrideCreatedAt.getTime()) * BigInt(1000000)
|
||||
: undefined,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
return await callback(
|
||||
{
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext,
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
const { existingRun, idempotencyKey, incomplete, isError } = options;
|
||||
const { repository, store } = await this.#getEventRepository(request, parentStore);
|
||||
|
||||
return await repository.traceEvent(
|
||||
`${request.taskId} (cached)`,
|
||||
{
|
||||
context: request.options?.traceContext,
|
||||
spanParentAsLink: request.options?.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: request.environment,
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
|
||||
},
|
||||
style: {
|
||||
icon: "task-cached",
|
||||
},
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
incomplete,
|
||||
isError,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
//log a message
|
||||
await repository.recordEvent(
|
||||
`There's an existing run for idempotencyKey: ${idempotencyKey}`,
|
||||
{
|
||||
taskSlug: request.taskId,
|
||||
environment: request.environment,
|
||||
attributes: {
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
context: request.options?.traceContext,
|
||||
parentId: event.spanId,
|
||||
}
|
||||
);
|
||||
|
||||
return await callback(
|
||||
{
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext,
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
const { existingRun, debounceKey, incomplete, isError } = options;
|
||||
const { repository, store } = await this.#getEventRepository(request, parentStore);
|
||||
|
||||
return await repository.traceEvent(
|
||||
`${request.taskId} (debounced)`,
|
||||
{
|
||||
context: request.options?.traceContext,
|
||||
spanParentAsLink: request.options?.spanParentAsLink,
|
||||
kind: "SERVER",
|
||||
environment: request.environment,
|
||||
taskSlug: request.taskId,
|
||||
attributes: {
|
||||
properties: {
|
||||
[SemanticInternalAttributes.ORIGINAL_RUN_ID]: existingRun.friendlyId,
|
||||
},
|
||||
style: {
|
||||
icon: "task-cached",
|
||||
},
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
incomplete,
|
||||
isError,
|
||||
immediate: true,
|
||||
},
|
||||
async (event, traceContext, traceparent) => {
|
||||
// Log a message about the debounced trigger
|
||||
await repository.recordEvent(`Debounced: using existing run with key "${debounceKey}"`, {
|
||||
taskSlug: request.taskId,
|
||||
environment: request.environment,
|
||||
attributes: {
|
||||
runId: existingRun.friendlyId,
|
||||
},
|
||||
context: request.options?.traceContext,
|
||||
parentId: event.spanId,
|
||||
});
|
||||
|
||||
return await callback(
|
||||
{
|
||||
traceId: event.traceId,
|
||||
spanId: event.spanId,
|
||||
traceContext,
|
||||
traceparent,
|
||||
setAttribute: (key, value) => event.setAttribute(key as any, value),
|
||||
failWithError: event.failWithError.bind(event),
|
||||
stop: event.stop.bind(event),
|
||||
},
|
||||
store
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { type IOPacket, packetRequiresOffloading, tryCatch } from "@trigger.dev/core/v3";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { env } from "~/env.server";
|
||||
import { uploadPacketToObjectStore } from "~/v3/objectStore.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
function packetExtensionForDataType(dataType: string): string {
|
||||
switch (dataType) {
|
||||
case "application/json":
|
||||
case "application/super+json":
|
||||
return "json";
|
||||
case "text/plain":
|
||||
return "txt";
|
||||
default:
|
||||
return "txt";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Offloads large waitpoint completion payloads to object store (same threshold and
|
||||
* upload path pattern as DefaultPayloadProcessor). Object key prefix should use the
|
||||
* waitpoint friendly id folder, e.g. `${WaitpointId.toFriendlyId(internalId)}/token`.
|
||||
* Replaces no-op conditionallyExportPacket usage in webapp routes where apiClientManager is unset.
|
||||
*/
|
||||
export async function processWaitpointCompletionPacket(
|
||||
packet: IOPacket,
|
||||
environment: AuthenticatedEnvironment,
|
||||
pathPrefix: string
|
||||
): Promise<IOPacket> {
|
||||
if (!packet.data) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const { needsOffloading, size: _size } = packetRequiresOffloading(
|
||||
packet,
|
||||
env.TASK_PAYLOAD_OFFLOAD_THRESHOLD
|
||||
);
|
||||
|
||||
if (!needsOffloading) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
const filename = `${pathPrefix}.${packetExtensionForDataType(packet.dataType)}`;
|
||||
|
||||
const [uploadError, uploadedFilename] = await tryCatch(
|
||||
uploadPacketToObjectStore(
|
||||
filename,
|
||||
packet.data,
|
||||
packet.dataType,
|
||||
environment,
|
||||
env.OBJECT_STORE_DEFAULT_PROTOCOL
|
||||
)
|
||||
);
|
||||
|
||||
if (uploadError) {
|
||||
logger.error("Failed to upload large waitpoint to object store", {
|
||||
error: uploadError,
|
||||
filename,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
throw new ServiceValidationError("Failed to upload large waitpoint to object store", 500);
|
||||
}
|
||||
|
||||
return {
|
||||
data: uploadedFilename!,
|
||||
dataType: "application/store",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { WorkerQueueClass } from "@trigger.dev/core/v3/workers";
|
||||
import { FEATURE_FLAG, FeatureFlagCatalog } from "~/v3/featureFlags";
|
||||
|
||||
/**
|
||||
* Suffix appended to a region's worker queue name to route scheduled-lineage
|
||||
* runs onto their own Redis list (e.g. `us-nyc-3` -> `us-nyc-3:scheduled`). A
|
||||
* dedicated consumer fleet dequeues the suffixed list so the top-of-hour
|
||||
* scheduled-cron herd can't starve standard/agent run startup. The worker queue
|
||||
* name is opaque everywhere downstream (it's only ever `:`-joined into a Redis
|
||||
* key and persisted on the run), so encoding the class in the suffix needs no
|
||||
* Lua, envelope, or resolver changes.
|
||||
*/
|
||||
export const SCHEDULED_WORKER_QUEUE_SUFFIX = ":scheduled";
|
||||
|
||||
/**
|
||||
* Recover the base region a worker queue belongs to by stripping any split
|
||||
* suffix (e.g. `us-nyc-3:scheduled` -> `us-nyc-3`). Region/masterQueue names are
|
||||
* either `<name>` or `<projectId>-<name>` and never contain a colon, so the
|
||||
* region is everything before the first `:`. Use this wherever a worker queue is
|
||||
* read as a region — for display, filtering, or as a region override — so
|
||||
* scheduled-split runs group under their real region instead of a phantom one.
|
||||
* Idempotent; returns the input unchanged when there's no suffix. A nullish
|
||||
* worker queue (e.g. from a synthetic run snapshot) passes straight through.
|
||||
*/
|
||||
export function baseWorkerQueue(workerQueue: string): string;
|
||||
export function baseWorkerQueue(workerQueue: string | null | undefined): string | null | undefined;
|
||||
export function baseWorkerQueue(workerQueue: string | null | undefined): string | null | undefined {
|
||||
if (workerQueue == null) {
|
||||
return workerQueue;
|
||||
}
|
||||
|
||||
const colon = workerQueue.indexOf(":");
|
||||
return colon === -1 ? workerQueue : workerQueue.slice(0, colon);
|
||||
}
|
||||
|
||||
/**
|
||||
* User-facing region for read surfaces: the explicit geo region if set, else the
|
||||
* region derived from the worker queue, else undefined. Use everywhere a run's
|
||||
* region is displayed so an empty queue never surfaces as `""` and all surfaces
|
||||
* agree. Not for query keys — those want the raw worker queue, not this fallback.
|
||||
*/
|
||||
export function regionForDisplay(
|
||||
region: string | null | undefined,
|
||||
workerQueue: string | null | undefined
|
||||
): string | undefined {
|
||||
return region || (workerQueue ? baseWorkerQueue(workerQueue) : undefined);
|
||||
}
|
||||
|
||||
/** `TriggerSource` value used for runs originating from a schedule. */
|
||||
const SCHEDULE_TRIGGER_SOURCE = "schedule";
|
||||
|
||||
/**
|
||||
* Resolve whether the scheduled worker-queue split is enabled for a run, reading
|
||||
* only the in-memory org feature-flags JSON (already loaded on the authenticated
|
||||
* environment) — never a DB query, so it is safe on the trigger hot path.
|
||||
*
|
||||
* Precedence: a per-org override wins in BOTH directions; the global default is
|
||||
* used only when the org has not set the flag.
|
||||
*/
|
||||
export function resolveScheduledQueueSplitEnabled({
|
||||
orgFeatureFlags,
|
||||
globalDefault,
|
||||
}: {
|
||||
orgFeatureFlags: Record<string, unknown> | null | undefined;
|
||||
globalDefault: boolean;
|
||||
}): boolean {
|
||||
const override = orgFeatureFlags?.[FEATURE_FLAG.workerQueueScheduledSplitEnabled];
|
||||
|
||||
if (override !== undefined) {
|
||||
const parsed =
|
||||
FeatureFlagCatalog[FEATURE_FLAG.workerQueueScheduledSplitEnabled].safeParse(override);
|
||||
|
||||
if (parsed.success) {
|
||||
return parsed.data;
|
||||
}
|
||||
}
|
||||
|
||||
return globalDefault;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the worker queue a run should be enqueued onto. Runs in a scheduled
|
||||
* lineage (`rootTriggerSource === "schedule"`, which propagates from a scheduled
|
||||
* root down to every descendant) route to the suffixed list when the split is
|
||||
* enabled; everything else is unchanged. Idempotent — never double-suffixes.
|
||||
*/
|
||||
export function workerQueueForRun({
|
||||
workerQueue,
|
||||
rootTriggerSource,
|
||||
splitEnabled,
|
||||
}: {
|
||||
workerQueue: string;
|
||||
rootTriggerSource: string | undefined;
|
||||
splitEnabled: boolean;
|
||||
}): string {
|
||||
if (
|
||||
!splitEnabled ||
|
||||
rootTriggerSource !== SCHEDULE_TRIGGER_SOURCE ||
|
||||
workerQueue.endsWith(SCHEDULED_WORKER_QUEUE_SUFFIX)
|
||||
) {
|
||||
return workerQueue;
|
||||
}
|
||||
|
||||
return `${workerQueue}${SCHEDULED_WORKER_QUEUE_SUFFIX}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Consumer-side counterpart to {@link workerQueueForRun}: given a worker's base
|
||||
* (region) queue and the requested queue class, return the worker queue to
|
||||
* dequeue from. `"scheduled"` targets the suffixed list; anything else is the
|
||||
* base queue. The server always derives this from the authenticated worker's
|
||||
* own `masterQueue`, so a token can only ever reach its own region's queues.
|
||||
* Idempotent — never double-suffixes.
|
||||
*/
|
||||
export function workerQueueForClass(
|
||||
masterQueue: string,
|
||||
queueClass: WorkerQueueClass | undefined
|
||||
): string {
|
||||
if (queueClass === "scheduled" && !masterQueue.endsWith(SCHEDULED_WORKER_QUEUE_SUFFIX)) {
|
||||
return `${masterQueue}${SCHEDULED_WORKER_QUEUE_SUFFIX}`;
|
||||
}
|
||||
|
||||
return masterQueue;
|
||||
}
|
||||
|
||||
export function parseDisabledWorkerQueues(raw: string | undefined): Set<string> {
|
||||
return new Set(
|
||||
(raw ?? "")
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
export function matchesDisabledWorkerQueue(
|
||||
workerQueue: string,
|
||||
disabledWorkerQueues: ReadonlySet<string>
|
||||
): boolean {
|
||||
if (disabledWorkerQueues.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
disabledWorkerQueues.has(workerQueue) || disabledWorkerQueues.has(baseWorkerQueue(workerQueue))
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { BackgroundWorker, TaskRun } from "@trigger.dev/database";
|
||||
import type { IOPacket, TaskRunError, TriggerTaskRequestBody } from "@trigger.dev/core/v3";
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import type { ReportUsagePlan } from "@trigger.dev/platform";
|
||||
|
||||
export type TriggerTaskServiceOptions = {
|
||||
idempotencyKey?: string;
|
||||
idempotencyKeyExpiresAt?: Date;
|
||||
triggerVersion?: string;
|
||||
traceContext?: Record<string, unknown>;
|
||||
spanParentAsLink?: boolean;
|
||||
parentAsLinkType?: "replay" | "trigger";
|
||||
batchId?: string;
|
||||
batchIndex?: number;
|
||||
customIcon?: string;
|
||||
runFriendlyId?: string;
|
||||
skipChecks?: boolean;
|
||||
oneTimeUseToken?: string;
|
||||
overrideCreatedAt?: Date;
|
||||
planType?: string;
|
||||
};
|
||||
|
||||
// domain/triggerTask.ts
|
||||
export type TriggerTaskRequest = {
|
||||
taskId: string;
|
||||
friendlyId: string;
|
||||
environment: AuthenticatedEnvironment;
|
||||
body: TriggerTaskRequestBody;
|
||||
options?: TriggerTaskServiceOptions;
|
||||
};
|
||||
|
||||
export type TriggerTaskResult = {
|
||||
run: TaskRun;
|
||||
isCached: boolean;
|
||||
error?: TaskRunError;
|
||||
};
|
||||
|
||||
export type QueueValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
maximumSize: number;
|
||||
queueSize: number;
|
||||
};
|
||||
|
||||
export type QueueProperties = {
|
||||
queueName: string;
|
||||
lockedQueueId?: string;
|
||||
taskTtl?: string | null;
|
||||
taskKind?: string;
|
||||
};
|
||||
|
||||
export type LockedBackgroundWorker = Pick<
|
||||
BackgroundWorker,
|
||||
"id" | "version" | "sdkVersion" | "cliVersion"
|
||||
>;
|
||||
|
||||
// Core domain interfaces
|
||||
export interface QueueManager {
|
||||
resolveQueueProperties(
|
||||
request: TriggerTaskRequest,
|
||||
lockedBackgroundWorker?: LockedBackgroundWorker
|
||||
): Promise<QueueProperties>;
|
||||
validateQueueLimits(
|
||||
env: AuthenticatedEnvironment,
|
||||
queueName: string,
|
||||
itemsToAdd?: number
|
||||
): Promise<QueueValidationResult>;
|
||||
getWorkerQueue(
|
||||
env: AuthenticatedEnvironment,
|
||||
regionOverride?: string
|
||||
): Promise<{ masterQueue: string; enableFastPath: boolean } | undefined>;
|
||||
}
|
||||
|
||||
export interface PayloadProcessor {
|
||||
process(request: TriggerTaskRequest): Promise<IOPacket>;
|
||||
}
|
||||
|
||||
export interface TagValidationParams {
|
||||
tags?: string[] | string;
|
||||
}
|
||||
|
||||
export interface EntitlementValidationParams {
|
||||
environment: AuthenticatedEnvironment;
|
||||
}
|
||||
|
||||
export interface MaxAttemptsValidationParams {
|
||||
taskId: string;
|
||||
attempt: number;
|
||||
}
|
||||
|
||||
export interface ParentRunValidationParams {
|
||||
taskId: string;
|
||||
parentRun?: TaskRun;
|
||||
resumeParentOnCompletion?: boolean;
|
||||
}
|
||||
|
||||
export type ValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type EntitlementValidationResult =
|
||||
| {
|
||||
ok: true;
|
||||
plan?: ReportUsagePlan;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export interface TriggerTaskValidator {
|
||||
validateTags(params: TagValidationParams): ValidationResult;
|
||||
validateEntitlement(params: EntitlementValidationParams): Promise<EntitlementValidationResult>;
|
||||
validateMaxAttempts(params: MaxAttemptsValidationParams): ValidationResult;
|
||||
validateParentRun(params: ParentRunValidationParams): ValidationResult;
|
||||
}
|
||||
|
||||
export type TracedEventSpan = {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
traceContext: Record<string, unknown>;
|
||||
traceparent?: {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
};
|
||||
setAttribute: (key: string, value: string) => void;
|
||||
failWithError: (error: TaskRunError) => void;
|
||||
/**
|
||||
* Stop the span without writing any event.
|
||||
* Used when a debounced run is returned - the span for the debounced
|
||||
* trigger is created separately via traceDebouncedRun.
|
||||
*/
|
||||
stop: () => void;
|
||||
};
|
||||
|
||||
export interface TraceEventConcern {
|
||||
traceRun<T>(
|
||||
request: TriggerTaskRequest,
|
||||
parentStore: string | undefined,
|
||||
callback: (span: TracedEventSpan, store: string) => Promise<T>
|
||||
): Promise<T>;
|
||||
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>;
|
||||
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>;
|
||||
}
|
||||
|
||||
export type TriggerRacepoints = "idempotencyKey";
|
||||
|
||||
export interface TriggerRacepointSystem {
|
||||
waitForRacepoint(options: { racepoint: TriggerRacepoints; id: string }): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { MAX_TAGS_PER_RUN } from "~/models/taskRunTag.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getEntitlement } from "~/services/platform.v3.server";
|
||||
import { MAX_ATTEMPTS } from "~/v3/services/triggerTask.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import type {
|
||||
EntitlementValidationParams,
|
||||
EntitlementValidationResult,
|
||||
MaxAttemptsValidationParams,
|
||||
ParentRunValidationParams,
|
||||
TagValidationParams,
|
||||
TriggerTaskValidator,
|
||||
ValidationResult,
|
||||
} from "../types";
|
||||
import { validateProductionEntitlement } from "./validateProductionEntitlement.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
export class DefaultTriggerTaskValidator implements TriggerTaskValidator {
|
||||
validateTags(params: TagValidationParams): ValidationResult {
|
||||
const { tags } = params;
|
||||
|
||||
if (!tags) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (typeof tags === "string") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
if (tags.length > MAX_TAGS_PER_RUN) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new ServiceValidationError(
|
||||
`Runs can only have ${MAX_TAGS_PER_RUN} tags, you're trying to set ${tags.length}.`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async validateEntitlement(
|
||||
params: EntitlementValidationParams
|
||||
): Promise<EntitlementValidationResult> {
|
||||
return validateProductionEntitlement(params, getEntitlement);
|
||||
}
|
||||
|
||||
validateMaxAttempts(params: MaxAttemptsValidationParams): ValidationResult {
|
||||
const { taskId, attempt } = params;
|
||||
|
||||
if (attempt > MAX_ATTEMPTS) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new ServiceValidationError(
|
||||
`Failed to trigger ${taskId} after ${MAX_ATTEMPTS} attempts.`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
validateParentRun(params: ParentRunValidationParams): ValidationResult {
|
||||
const { taskId, parentRun, resumeParentOnCompletion } = params;
|
||||
|
||||
// If there's no parent run specified, that's fine
|
||||
if (!parentRun) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// If we're not resuming the parent, we don't need to validate its status
|
||||
if (!resumeParentOnCompletion) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// Check if the parent run is in a final state
|
||||
if (isFinalRunStatus(parentRun.status)) {
|
||||
logger.debug("Parent run is in a terminal state", {
|
||||
parentRun,
|
||||
});
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
error: new ServiceValidationError(
|
||||
`Cannot trigger ${taskId} as the parent run has a status of ${parentRun.status}`
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { EntitlementResult } from "~/services/billingLimit.schemas";
|
||||
import { OutOfEntitlementError } from "~/v3/outOfEntitlementError.server";
|
||||
import type { EntitlementValidationParams, EntitlementValidationResult } from "../types";
|
||||
|
||||
export type GetEntitlementFn = (organizationId: string) => Promise<EntitlementResult | undefined>;
|
||||
|
||||
export async function validateProductionEntitlement(
|
||||
params: EntitlementValidationParams,
|
||||
getEntitlementFn: GetEntitlementFn
|
||||
): Promise<EntitlementValidationResult> {
|
||||
const { environment } = params;
|
||||
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
const result = await getEntitlementFn(environment.organizationId);
|
||||
|
||||
if (result && result.hasAccess === false) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new OutOfEntitlementError(),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, plan: result?.plan };
|
||||
}
|
||||
Reference in New Issue
Block a user