From 424238aebca97afe206787e6ed49c7f54b08e60e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 13:17:11 +0000 Subject: [PATCH 1/9] feat: add global and task-level TTL settings Support TTL defaults at both the task definition level and globally in trigger.config.ts, with per-trigger overrides still taking precedence. Precedence order: per-trigger TTL > task-level TTL > dev default (10m) https://claude.ai/code/session_01Swj2TA2crHC29m1ynWwEUN --- .changeset/quiet-dogs-fly.md | 7 +++++ .../runEngine/services/triggerTask.server.ts | 28 ++++++++++++++++--- .../services/createBackgroundWorker.server.ts | 4 ++- .../migration.sql | 2 ++ .../database/prisma/schema.prisma | 2 ++ .../src/entryPoints/dev-index-worker.ts | 14 ++++++++++ .../src/entryPoints/managed-index-worker.ts | 14 ++++++++++ packages/core/src/v3/config.ts | 19 +++++++++++++ packages/core/src/v3/schemas/schemas.ts | 1 + packages/core/src/v3/types/tasks.ts | 23 +++++++++++++++ packages/trigger-sdk/src/v3/shared.ts | 2 ++ 11 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-dogs-fly.md create mode 100644 internal-packages/database/prisma/migrations/20260309120000_add_ttl_to_background_worker_task/migration.sql diff --git a/.changeset/quiet-dogs-fly.md b/.changeset/quiet-dogs-fly.md new file mode 100644 index 00000000000..e6017304760 --- /dev/null +++ b/.changeset/quiet-dogs-fly.md @@ -0,0 +1,7 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add support for setting TTL (time-to-live) defaults at the task level and globally in trigger.config.ts, with per-trigger overrides still taking precedence diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 2cc849e78de..5b2d0279b8c 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -190,10 +190,30 @@ export class RunEngineTriggerTaskService { } } - const ttl = - typeof body.options?.ttl === "number" - ? stringifyDuration(body.options?.ttl) - : body.options?.ttl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined); + // Resolve TTL with precedence: per-trigger > task-level > dev default + let ttl: string | undefined; + + if (body.options?.ttl !== undefined) { + // Per-trigger TTL takes highest priority + ttl = + typeof body.options.ttl === "number" + ? stringifyDuration(body.options.ttl) + : body.options.ttl; + } else { + // Look up task-level TTL default from BackgroundWorkerTask + const taskDefaults = await this.prisma.backgroundWorkerTask.findFirst({ + where: { + slug: taskId, + projectId: environment.projectId, + runtimeEnvironmentId: environment.id, + }, + select: { ttl: true }, + orderBy: { createdAt: "desc" }, + }); + + ttl = + taskDefaults?.ttl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined); + } // Get parent run if specified const parentRun = body.options?.parentRunId diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index 2938164b74b..a958364b1f9 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -5,7 +5,7 @@ import { QueueManifest, TaskResource, } from "@trigger.dev/core/v3"; -import { BackgroundWorkerId } from "@trigger.dev/core/v3/isomorphic"; +import { BackgroundWorkerId, stringifyDuration } from "@trigger.dev/core/v3/isomorphic"; import type { BackgroundWorker, TaskQueue, TaskQueueType } from "@trigger.dev/database"; import cronstrue from "cronstrue"; import { Prisma, PrismaClientOrTransaction } from "~/db.server"; @@ -280,6 +280,8 @@ async function createWorkerTask( triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD", fileId: tasksToBackgroundFiles?.get(task.id) ?? null, maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null, + ttl: + typeof task.ttl === "number" ? stringifyDuration(task.ttl) ?? null : task.ttl ?? null, queueId: queue.id, payloadSchema: task.payloadSchema as any, }, diff --git a/internal-packages/database/prisma/migrations/20260309120000_add_ttl_to_background_worker_task/migration.sql b/internal-packages/database/prisma/migrations/20260309120000_add_ttl_to_background_worker_task/migration.sql new file mode 100644 index 00000000000..569fb94643b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260309120000_add_ttl_to_background_worker_task/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN IF NOT EXISTS "ttl" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index f6986be42c0..6fb4e7e82bb 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -569,6 +569,8 @@ model BackgroundWorkerTask { maxDurationInSeconds Int? + ttl String? + triggerSource TaskTriggerSource @default(STANDARD) payloadSchema Json? diff --git a/packages/cli-v3/src/entryPoints/dev-index-worker.ts b/packages/cli-v3/src/entryPoints/dev-index-worker.ts index da5c6ee7508..a17c16f6f98 100644 --- a/packages/cli-v3/src/entryPoints/dev-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/dev-index-worker.ts @@ -131,6 +131,20 @@ if (typeof config.maxDuration === "number") { }); } +// If the config has a TTL, we need to apply it to all tasks that don't have a TTL +if (config.ttl !== undefined) { + tasks = tasks.map((task) => { + if (task.ttl === undefined) { + return { + ...task, + ttl: config.ttl, + } satisfies TaskManifest; + } + + return task; + }); +} + // If the config has a machine preset, we need to apply it to all tasks that don't have a machine preset if (typeof config.machine === "string") { tasks = tasks.map((task) => { diff --git a/packages/cli-v3/src/entryPoints/managed-index-worker.ts b/packages/cli-v3/src/entryPoints/managed-index-worker.ts index 5ff9f1b62ed..4d48dd3d673 100644 --- a/packages/cli-v3/src/entryPoints/managed-index-worker.ts +++ b/packages/cli-v3/src/entryPoints/managed-index-worker.ts @@ -131,6 +131,20 @@ if (typeof config.maxDuration === "number") { }); } +// If the config has a TTL, we need to apply it to all tasks that don't have a TTL +if (config.ttl !== undefined) { + tasks = tasks.map((task) => { + if (task.ttl === undefined) { + return { + ...task, + ttl: config.ttl, + } satisfies TaskManifest; + } + + return task; + }); +} + // If the config has a machine preset, we need to apply it to all tasks that don't have a machine preset if (typeof config.machine === "string") { tasks = tasks.map((task) => { diff --git a/packages/core/src/v3/config.ts b/packages/core/src/v3/config.ts index bf1e6c687c8..40334f04280 100644 --- a/packages/core/src/v3/config.ts +++ b/packages/core/src/v3/config.ts @@ -176,6 +176,25 @@ export type TriggerConfig = { */ maxDuration: number; + /** + * Set a default time-to-live (TTL) for all task runs in the project. If a run is not executed within this time, it will be removed from the queue and never execute. + * + * This can be a string like "1h" (1 hour), "30m" (30 minutes), "1d" (1 day), or a number of seconds. + * + * You can override this on a per-task basis by setting the `ttl` option on the task definition, or per-trigger by setting the `ttl` option when triggering. + * + * @example + * + * ```ts + * export default defineConfig({ + * project: "my-project", + * maxDuration: 3600, + * ttl: "1h", + * }); + * ``` + */ + ttl?: string | number; + /** * Enable console logging while running the dev CLI. This will print out logs from console.log, console.warn, and console.error. By default all logs are sent to the trigger.dev backend, and not logged to the console. */ diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 233068c0b7b..472067f8a63 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -189,6 +189,7 @@ const taskMetadata = { triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), + ttl: z.string().or(z.number().nonnegative()).optional(), payloadSchema: z.unknown().optional(), }; diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index 3b8b2e9ecdd..4e977662096 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -277,6 +277,29 @@ type CommonTaskOptions< */ maxDuration?: number; + /** + * Set a default time-to-live for runs of this task. If the run is not executed within this time, it will be removed from the queue and never execute. + * + * This can be a string like "1h" (1 hour), "30m" (30 minutes), "1d" (1 day), or a number of seconds. + * + * If omitted it will use the value in your `trigger.config.ts` file, if set. + * + * You can override this on a per-trigger basis by setting the `ttl` option when triggering the task. + * + * @example + * + * ```ts + * export const myTask = task({ + * id: "my-task", + * ttl: "10m", + * run: async (payload) => { + * //... + * }, + * }); + * ``` + */ + ttl?: string | number; + /** This gets called when a task is triggered. It's where you put the code you want to execute. * * @param payload - The payload that is passed to your task when it's triggered. This must be JSON serializable. diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index c03732c12ea..c69bceeb535 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -236,6 +236,7 @@ export function createTask< retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined, machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine, maxDuration: params.maxDuration, + ttl: params.ttl, payloadSchema: params.jsonSchema, fns: { run: params.run, @@ -367,6 +368,7 @@ export function createSchemaTask< retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined, machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine, maxDuration: params.maxDuration, + ttl: params.ttl, fns: { run: params.run, parsePayload, From aec70594fae8a941ddb29f379db0ea32c7f8fb02 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 14:12:56 +0000 Subject: [PATCH 2/9] fix: eliminate extra DB query for task TTL at trigger time Piggyback on existing BackgroundWorkerTask query in queue resolution instead of adding a separate findFirst in the hot trigger path. https://claude.ai/code/session_01Swj2TA2crHC29m1ynWwEUN --- .../app/runEngine/concerns/queues.server.ts | 24 +++++++--- .../runEngine/services/triggerTask.server.ts | 46 +++++++------------ apps/webapp/app/runEngine/types.ts | 1 + 3 files changed, 36 insertions(+), 35 deletions(-) diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index 12b0b29c5ff..ba32872ddd3 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -73,6 +73,7 @@ export class DefaultQueueManager implements QueueManager { ): Promise { let queueName: string; let lockedQueueId: string | undefined; + let taskTtl: string | null | undefined; // Determine queue name based on lockToVersion and provided options if (lockedBackgroundWorker) { @@ -134,6 +135,7 @@ export class DefaultQueueManager implements QueueManager { // Use the task's default queue name queueName = lockedTask.queue.name; lockedQueueId = lockedTask.queue.id; + taskTtl = lockedTask.ttl; } } else { // Task is not locked to a specific version, use regular logic @@ -145,7 +147,9 @@ export class DefaultQueueManager implements QueueManager { } // Get queue name using the helper for non-locked case (handles provided name or finds default) - queueName = await this.getQueueName(request); + const taskInfo = await this.getTaskQueueInfo(request); + queueName = taskInfo.queueName; + taskTtl = taskInfo.taskTtl; } // Sanitize the final determined queue name once @@ -161,17 +165,25 @@ export class DefaultQueueManager implements QueueManager { return { queueName, lockedQueueId, + taskTtl, }; } async getQueueName(request: TriggerTaskRequest): Promise { + const result = await this.getTaskQueueInfo(request); + return result.queueName; + } + + private async getTaskQueueInfo( + request: TriggerTaskRequest + ): Promise<{ queueName: string; taskTtl?: string | null }> { const { taskId, environment, body } = request; const { queue } = body.options ?? {}; // Use extractQueueName to handle double-wrapped queue objects const queueName = extractQueueName(queue); if (queueName) { - return queueName; + return { queueName }; } const defaultQueueName = `task/${taskId}`; @@ -185,7 +197,7 @@ export class DefaultQueueManager implements QueueManager { environmentId: environment.id, }); - return defaultQueueName; + return { queueName: defaultQueueName }; } const task = await this.prisma.backgroundWorkerTask.findFirst({ @@ -205,7 +217,7 @@ export class DefaultQueueManager implements QueueManager { environmentId: environment.id, }); - return defaultQueueName; + return { queueName: defaultQueueName }; } if (!task.queue) { @@ -215,10 +227,10 @@ export class DefaultQueueManager implements QueueManager { queueConfig: task.queueConfig, }); - return defaultQueueName; + return { queueName: defaultQueueName, taskTtl: task.ttl }; } - return task.queue.name ?? defaultQueueName; + return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl }; } async validateQueueLimits( diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 5b2d0279b8c..7b88a806b28 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -190,31 +190,6 @@ export class RunEngineTriggerTaskService { } } - // Resolve TTL with precedence: per-trigger > task-level > dev default - let ttl: string | undefined; - - if (body.options?.ttl !== undefined) { - // Per-trigger TTL takes highest priority - ttl = - typeof body.options.ttl === "number" - ? stringifyDuration(body.options.ttl) - : body.options.ttl; - } else { - // Look up task-level TTL default from BackgroundWorkerTask - const taskDefaults = await this.prisma.backgroundWorkerTask.findFirst({ - where: { - slug: taskId, - projectId: environment.projectId, - runtimeEnvironmentId: environment.id, - }, - select: { ttl: true }, - orderBy: { createdAt: "desc" }, - }); - - ttl = - taskDefaults?.ttl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined); - } - // Get parent run if specified const parentRun = body.options?.parentRunId ? await this.prisma.taskRun.findFirst({ @@ -270,10 +245,23 @@ export class RunEngineTriggerTaskService { }) : undefined; - const { queueName, lockedQueueId } = await this.queueConcern.resolveQueueProperties( - triggerRequest, - lockedToBackgroundWorker ?? undefined - ); + const { queueName, lockedQueueId, taskTtl } = + 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( diff --git a/apps/webapp/app/runEngine/types.ts b/apps/webapp/app/runEngine/types.ts index 3fc8d8034b7..c0fef6f9c22 100644 --- a/apps/webapp/app/runEngine/types.ts +++ b/apps/webapp/app/runEngine/types.ts @@ -48,6 +48,7 @@ export type QueueValidationResult = export type QueueProperties = { queueName: string; lockedQueueId?: string; + taskTtl?: string | null; }; export type LockedBackgroundWorker = Pick< From 5a68fa062ec3c4305cb0b73afc9a5cf833f8c4be Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 14:22:56 +0000 Subject: [PATCH 3/9] docs: add hot path performance guidance to webapp CLAUDE.md Document the two-stage resolution pattern and the rule against adding DB queries to the trigger hot path, so future changes don't regress trigger throughput. https://claude.ai/code/session_01Swj2TA2crHC29m1ynWwEUN --- apps/webapp/CLAUDE.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index d1533e9808a..a8d96176f97 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -56,3 +56,14 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O - `app/v3/sharedSocketConnection.ts` Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) branch on `RunEngineVersion` to support both V1 and V2. When editing these, only modify V2 code paths. + +## Performance: Trigger Hot Path + +The `triggerTask.server.ts` service is the **highest-throughput code path** in the system. Every API trigger call goes through it. Keep it fast: + +- **Do NOT add database queries** to the trigger path. The only acceptable DB query for task defaults (TTL, etc.) is the single existing `backgroundWorkerTask.findFirst()` call with a minimal `select`. +- **Two-stage resolution pattern**: Task metadata is resolved in two stages by design: + 1. **Trigger time** (`triggerTask.server.ts`): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides. + 2. **Dequeue time** (`dequeueSystem.ts`): Full `BackgroundWorkerTask` is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults. +- If you need to add a new task-level default, **add it to the existing `select` clause** in the `backgroundWorkerTask.findFirst()` query — do NOT add a second query. If the default doesn't need to be known at trigger time, resolve it at dequeue time instead. +- Batch triggers (`batchTriggerV3.server.ts`) follow the same pattern — keep batch paths equally fast. From 12d59678d82afe6fff4f8fcb12f87822c8880b72 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 15:00:09 +0000 Subject: [PATCH 4/9] fix: resolve task TTL even when queue is overridden When a caller provides a custom queue name (via options.queue), the queue concern returned early without looking up the task's TTL. This caused task-level and config-level TTL to be silently ignored in favor of just the dev default. Now both the locked-worker+custom-queue and non-locked+ custom-queue paths fetch task TTL via minimal select queries. https://claude.ai/code/session_01Swj2TA2crHC29m1ynWwEUN --- apps/webapp/CLAUDE.md | 2 +- .../app/runEngine/concerns/queues.server.ts | 38 ++++++++++++++++++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index a8d96176f97..f3253a305b9 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -61,7 +61,7 @@ Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) bran The `triggerTask.server.ts` service is the **highest-throughput code path** in the system. Every API trigger call goes through it. Keep it fast: -- **Do NOT add database queries** to the trigger path. The only acceptable DB query for task defaults (TTL, etc.) is the single existing `backgroundWorkerTask.findFirst()` call with a minimal `select`. +- **Do NOT add database queries** to the trigger path. Task TTL is resolved via the queue concern's existing `backgroundWorkerTask.findFirst()` calls. When a custom queue override is provided, a minimal `select: { ttl: true }` fallback query fetches the task's TTL separately — this is acceptable because custom queue overrides are rare. - **Two-stage resolution pattern**: Task metadata is resolved in two stages by design: 1. **Trigger time** (`triggerTask.server.ts`): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides. 2. **Dequeue time** (`dequeueSystem.ts`): Full `BackgroundWorkerTask` is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults. diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index ba32872ddd3..f70ea5b83de 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -99,6 +99,13 @@ export class DefaultQueueManager implements QueueManager { // Use the validated queue name directly queueName = specifiedQueue.name; lockedQueueId = specifiedQueue.id; + + // Still need task TTL even when queue is overridden + taskTtl = await this.getTaskTtl( + request.taskId, + request.environment.id, + lockedBackgroundWorker.id + ); } else { // No specific queue name provided, use the default queue for the task on the locked worker const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({ @@ -183,7 +190,9 @@ export class DefaultQueueManager implements QueueManager { // Use extractQueueName to handle double-wrapped queue objects const queueName = extractQueueName(queue); if (queueName) { - return { queueName }; + // Still need task TTL even when queue is overridden + const taskTtl = await this.getTaskTtlForEnvironment(taskId, environment); + return { queueName, taskTtl }; } const defaultQueueName = `task/${taskId}`; @@ -233,6 +242,33 @@ export class DefaultQueueManager implements QueueManager { return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl }; } + private async getTaskTtl( + taskId: string, + environmentId: string, + workerId: string + ): Promise { + const task = await this.prisma.backgroundWorkerTask.findFirst({ + where: { + workerId, + runtimeEnvironmentId: environmentId, + slug: taskId, + }, + select: { ttl: true }, + }); + return task?.ttl; + } + + private async getTaskTtlForEnvironment( + taskId: string, + environment: AuthenticatedEnvironment + ): Promise { + const worker = await findCurrentWorkerFromEnvironment(environment, this.prisma); + if (!worker) { + return undefined; + } + return this.getTaskTtl(taskId, environment.id, worker.id); + } + async validateQueueLimits( environment: AuthenticatedEnvironment, queueName: string, From 13e7b4aad9327bfd3b4071fb568f902936de6f0e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Mar 2026 15:00:09 +0000 Subject: [PATCH 5/9] fix: resolve task TTL even when queue is overridden --- apps/webapp/CLAUDE.md | 2 +- .../app/runEngine/concerns/queues.server.ts | 60 +++++++++---------- 2 files changed, 30 insertions(+), 32 deletions(-) diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md index a8d96176f97..fe974a89e64 100644 --- a/apps/webapp/CLAUDE.md +++ b/apps/webapp/CLAUDE.md @@ -61,7 +61,7 @@ Some services (e.g., `cancelTaskRun.server.ts`, `batchTriggerV3.server.ts`) bran The `triggerTask.server.ts` service is the **highest-throughput code path** in the system. Every API trigger call goes through it. Keep it fast: -- **Do NOT add database queries** to the trigger path. The only acceptable DB query for task defaults (TTL, etc.) is the single existing `backgroundWorkerTask.findFirst()` call with a minimal `select`. +- **Do NOT add database queries** to the trigger path. The only acceptable DB query for task defaults (TTL, etc.) is the single existing `backgroundWorkerTask.findFirst()` call in the queue concern. Piggyback on it instead of adding new queries. - **Two-stage resolution pattern**: Task metadata is resolved in two stages by design: 1. **Trigger time** (`triggerTask.server.ts`): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides. 2. **Dequeue time** (`dequeueSystem.ts`): Full `BackgroundWorkerTask` is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults. diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index ba32872ddd3..84fcb12a967 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -79,14 +79,35 @@ export class DefaultQueueManager implements QueueManager { if (lockedBackgroundWorker) { // Task is locked to a specific worker version const specifiedQueueName = extractQueueName(request.body.options?.queue); + + // Always fetch the task to get TTL (and default queue if no override) + const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({ + where: { + workerId: lockedBackgroundWorker.id, + runtimeEnvironmentId: request.environment.id, + slug: request.taskId, + }, + include: { + queue: true, + }, + }); + + if (!lockedTask) { + throw new ServiceValidationError( + `Task '${request.taskId}' not found on locked version '${lockedBackgroundWorker.version ?? "" + }'.` + ); + } + + taskTtl = lockedTask.ttl; + if (specifiedQueueName) { - // A specific queue name is provided + // A specific queue name is provided, validate it exists for the locked worker const specifiedQueue = await this.prisma.taskQueue.findFirst({ - // Validate it exists for the locked worker where: { name: specifiedQueueName, runtimeEnvironmentId: request.environment.id, - workers: { some: { id: lockedBackgroundWorker.id } }, // Ensure the queue is associated with any task of the locked worker + workers: { some: { id: lockedBackgroundWorker.id } }, }, }); @@ -100,25 +121,6 @@ export class DefaultQueueManager implements QueueManager { queueName = specifiedQueue.name; lockedQueueId = specifiedQueue.id; } else { - // No specific queue name provided, use the default queue for the task on the locked worker - const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({ - where: { - workerId: lockedBackgroundWorker.id, - runtimeEnvironmentId: request.environment.id, - slug: request.taskId, - }, - include: { - queue: true, - }, - }); - - if (!lockedTask) { - throw new ServiceValidationError( - `Task '${request.taskId}' not found on locked version '${lockedBackgroundWorker.version ?? "" - }'.` - ); - } - if (!lockedTask.queue) { // This case should ideally be prevented by earlier checks or schema constraints, // but handle it defensively. @@ -135,7 +137,6 @@ export class DefaultQueueManager implements QueueManager { // Use the task's default queue name queueName = lockedTask.queue.name; lockedQueueId = lockedTask.queue.id; - taskTtl = lockedTask.ttl; } } else { // Task is not locked to a specific version, use regular logic @@ -181,10 +182,7 @@ export class DefaultQueueManager implements QueueManager { const { queue } = body.options ?? {}; // Use extractQueueName to handle double-wrapped queue objects - const queueName = extractQueueName(queue); - if (queueName) { - return { queueName }; - } + const overriddenQueueName = extractQueueName(queue); const defaultQueueName = `task/${taskId}`; @@ -197,7 +195,7 @@ export class DefaultQueueManager implements QueueManager { environmentId: environment.id, }); - return { queueName: defaultQueueName }; + return { queueName: overriddenQueueName ?? defaultQueueName }; } const task = await this.prisma.backgroundWorkerTask.findFirst({ @@ -217,7 +215,7 @@ export class DefaultQueueManager implements QueueManager { environmentId: environment.id, }); - return { queueName: defaultQueueName }; + return { queueName: overriddenQueueName ?? defaultQueueName }; } if (!task.queue) { @@ -227,10 +225,10 @@ export class DefaultQueueManager implements QueueManager { queueConfig: task.queueConfig, }); - return { queueName: defaultQueueName, taskTtl: task.ttl }; + return { queueName: overriddenQueueName ?? defaultQueueName, taskTtl: task.ttl }; } - return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl }; + return { queueName: overriddenQueueName ?? task.queue.name ?? defaultQueueName, taskTtl: task.ttl }; } async validateQueueLimits( From 4d99f685c43c03bcc1e0ee940d8915dbd9431341 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:21:05 +0000 Subject: [PATCH 6/9] test: add TTL examples to hello-world reference project --- references/hello-world/src/trigger/example.ts | 1 + references/hello-world/src/trigger/schedule.ts | 1 + references/hello-world/trigger.config.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/references/hello-world/src/trigger/example.ts b/references/hello-world/src/trigger/example.ts index 8ecfdb033ed..5e04f57144f 100644 --- a/references/hello-world/src/trigger/example.ts +++ b/references/hello-world/src/trigger/example.ts @@ -5,6 +5,7 @@ import { fixedLengthTask } from "./batches.js"; export const helloWorldTask = task({ id: "hello-world", + ttl: "10m", retry: { maxAttempts: 3, minTimeoutInMs: 500, diff --git a/references/hello-world/src/trigger/schedule.ts b/references/hello-world/src/trigger/schedule.ts index 29c363d64ca..46a7d9a18ce 100644 --- a/references/hello-world/src/trigger/schedule.ts +++ b/references/hello-world/src/trigger/schedule.ts @@ -3,6 +3,7 @@ import { schedules } from "@trigger.dev/sdk/v3"; export const simpleSchedule = schedules.task({ id: "simple-schedule", cron: "0 0 * * *", + ttl: "30m", run: async (payload, { ctx }) => { return { message: "Hello, world!", diff --git a/references/hello-world/trigger.config.ts b/references/hello-world/trigger.config.ts index 2c3751b041f..e0b875cd6d1 100644 --- a/references/hello-world/trigger.config.ts +++ b/references/hello-world/trigger.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, logLevel: "debug", maxDuration: 3600, + ttl: "1h", retries: { enabledInDev: true, default: { From e90e21186eebeca7f9cb4bdaa27ae2cf529faa2d Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 10 Mar 2026 11:38:30 +0000 Subject: [PATCH 7/9] fix: add ttl field to TaskResource zod schema to prevent silent stripping --- packages/core/src/v3/schemas/resources.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts index 08764906ede..ce2e9bbb729 100644 --- a/packages/core/src/v3/schemas/resources.ts +++ b/packages/core/src/v3/schemas/resources.ts @@ -13,6 +13,7 @@ export const TaskResource = z.object({ triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), + ttl: z.string().or(z.number().nonnegative()).optional(), // JSONSchema type - using z.unknown() for runtime validation to accept JSONSchema7 payloadSchema: z.unknown().optional(), }); From d0529bad78dfdedc5683edce0b74b5ae4137def2 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:13:02 +0000 Subject: [PATCH 8/9] fix: add .int() to ttl schemas to match API validation --- packages/core/src/v3/schemas/resources.ts | 2 +- packages/core/src/v3/schemas/schemas.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts index ce2e9bbb729..60d755b72b9 100644 --- a/packages/core/src/v3/schemas/resources.ts +++ b/packages/core/src/v3/schemas/resources.ts @@ -13,7 +13,7 @@ export const TaskResource = z.object({ triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), - ttl: z.string().or(z.number().nonnegative()).optional(), + ttl: z.string().or(z.number().nonnegative().int()).optional(), // JSONSchema type - using z.unknown() for runtime validation to accept JSONSchema7 payloadSchema: z.unknown().optional(), }); diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 472067f8a63..f7f0b404076 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -189,7 +189,7 @@ const taskMetadata = { triggerSource: z.string().optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), - ttl: z.string().or(z.number().nonnegative()).optional(), + ttl: z.string().or(z.number().nonnegative().int()).optional(), payloadSchema: z.unknown().optional(), }; From caacba81a2f2ff6dc7e190e7782b1ecdb95ee7cc Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:21:02 +0000 Subject: [PATCH 9/9] fix: make taskTtl explicit in all getTaskQueueInfo return paths --- apps/webapp/app/runEngine/concerns/queues.server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index 84fcb12a967..473a4457329 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -195,7 +195,7 @@ export class DefaultQueueManager implements QueueManager { environmentId: environment.id, }); - return { queueName: overriddenQueueName ?? defaultQueueName }; + return { queueName: overriddenQueueName ?? defaultQueueName, taskTtl: undefined }; } const task = await this.prisma.backgroundWorkerTask.findFirst({ @@ -215,7 +215,7 @@ export class DefaultQueueManager implements QueueManager { environmentId: environment.id, }); - return { queueName: overriddenQueueName ?? defaultQueueName }; + return { queueName: overriddenQueueName ?? defaultQueueName, taskTtl: undefined }; } if (!task.queue) {