chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
@@ -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))
);
}