Skip to content

Commit 0e63f83

Browse files
authored
feat: add ttl support at task and config levels (#3196)
Add TTL (time-to-live) defaults at task-level and config-level, with precedence: per-trigger > task > config > dev default (10m). Docs PR: #3200 (merge after packages are released)
1 parent b075678 commit 0e63f83

File tree

18 files changed

+175
-25
lines changed

18 files changed

+175
-25
lines changed

.changeset/quiet-dogs-fly.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
"trigger.dev": patch
5+
---
6+
7+
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

apps/webapp/CLAUDE.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,17 @@ The `app/v3/` directory name is misleading - most code is actively used by V2. O
9999

100100
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.
101101

102+
## Performance: Trigger Hot Path
103+
104+
The `triggerTask.server.ts` service is the **highest-throughput code path** in the system. Every API trigger call goes through it. Keep it fast:
105+
106+
- **Do NOT add database queries** to `triggerTask.server.ts` or `batchTriggerV3.server.ts`. Task defaults (TTL, etc.) are resolved via `backgroundWorkerTask.findFirst()` in the queue concern (`queues.server.ts`) - one query per request, in mutually exclusive branches depending on locked/non-locked path. Piggyback on the existing query instead of adding new ones.
107+
- **Two-stage resolution pattern**: Task metadata is resolved in two stages by design:
108+
1. **Trigger time** (`triggerTask.server.ts`): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides.
109+
2. **Dequeue time** (`dequeueSystem.ts`): Full `BackgroundWorkerTask` is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults.
110+
- 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.
111+
- Batch triggers (`batchTriggerV3.server.ts`) follow the same pattern — keep batch paths equally fast.
112+
102113
## Prisma Query Patterns
103114

104115
- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues.

apps/webapp/app/runEngine/concerns/queues.server.ts

Lines changed: 55 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -73,19 +73,20 @@ export class DefaultQueueManager implements QueueManager {
7373
): Promise<QueueProperties> {
7474
let queueName: string;
7575
let lockedQueueId: string | undefined;
76+
let taskTtl: string | null | undefined;
7677

7778
// Determine queue name based on lockToVersion and provided options
7879
if (lockedBackgroundWorker) {
7980
// Task is locked to a specific worker version
8081
const specifiedQueueName = extractQueueName(request.body.options?.queue);
82+
8183
if (specifiedQueueName) {
82-
// A specific queue name is provided
84+
// A specific queue name is provided, validate it exists for the locked worker
8385
const specifiedQueue = await this.prisma.taskQueue.findFirst({
84-
// Validate it exists for the locked worker
8586
where: {
8687
name: specifiedQueueName,
8788
runtimeEnvironmentId: request.environment.id,
88-
workers: { some: { id: lockedBackgroundWorker.id } }, // Ensure the queue is associated with any task of the locked worker
89+
workers: { some: { id: lockedBackgroundWorker.id } },
8990
},
9091
});
9192

@@ -95,11 +96,26 @@ export class DefaultQueueManager implements QueueManager {
9596
}'.`
9697
);
9798
}
99+
98100
// Use the validated queue name directly
99101
queueName = specifiedQueue.name;
100102
lockedQueueId = specifiedQueue.id;
103+
104+
// Only fetch task for TTL if caller didn't provide a per-trigger TTL
105+
if (request.body.options?.ttl === undefined) {
106+
const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({
107+
where: {
108+
workerId: lockedBackgroundWorker.id,
109+
runtimeEnvironmentId: request.environment.id,
110+
slug: request.taskId,
111+
},
112+
select: { ttl: true },
113+
});
114+
115+
taskTtl = lockedTask?.ttl;
116+
}
101117
} else {
102-
// No specific queue name provided, use the default queue for the task on the locked worker
118+
// No queue override - fetch task with queue to get both default queue and TTL
103119
const lockedTask = await this.prisma.backgroundWorkerTask.findFirst({
104120
where: {
105121
workerId: lockedBackgroundWorker.id,
@@ -118,6 +134,8 @@ export class DefaultQueueManager implements QueueManager {
118134
);
119135
}
120136

137+
taskTtl = lockedTask.ttl;
138+
121139
if (!lockedTask.queue) {
122140
// This case should ideally be prevented by earlier checks or schema constraints,
123141
// but handle it defensively.
@@ -131,6 +149,7 @@ export class DefaultQueueManager implements QueueManager {
131149
}'.`
132150
);
133151
}
152+
134153
// Use the task's default queue name
135154
queueName = lockedTask.queue.name;
136155
lockedQueueId = lockedTask.queue.id;
@@ -145,7 +164,9 @@ export class DefaultQueueManager implements QueueManager {
145164
}
146165

147166
// Get queue name using the helper for non-locked case (handles provided name or finds default)
148-
queueName = await this.getQueueName(request);
167+
const taskInfo = await this.getTaskQueueInfo(request);
168+
queueName = taskInfo.queueName;
169+
taskTtl = taskInfo.taskTtl;
149170
}
150171

151172
// Sanitize the final determined queue name once
@@ -161,21 +182,27 @@ export class DefaultQueueManager implements QueueManager {
161182
return {
162183
queueName,
163184
lockedQueueId,
185+
taskTtl,
164186
};
165187
}
166188

167-
async getQueueName(request: TriggerTaskRequest): Promise<string> {
189+
private async getTaskQueueInfo(
190+
request: TriggerTaskRequest
191+
): Promise<{ queueName: string; taskTtl?: string | null }> {
168192
const { taskId, environment, body } = request;
169193
const { queue } = body.options ?? {};
170194

171195
// Use extractQueueName to handle double-wrapped queue objects
172-
const queueName = extractQueueName(queue);
173-
if (queueName) {
174-
return queueName;
175-
}
196+
const overriddenQueueName = extractQueueName(queue);
176197

177198
const defaultQueueName = `task/${taskId}`;
178199

200+
// When caller provides both a queue override and a per-trigger TTL,
201+
// we don't need any DB queries - the per-trigger TTL takes precedence
202+
if (overriddenQueueName && body.options?.ttl !== undefined) {
203+
return { queueName: overriddenQueueName, taskTtl: undefined };
204+
}
205+
179206
// Find the current worker for the environment
180207
const worker = await findCurrentWorkerFromEnvironment(environment, this.prisma);
181208

@@ -185,7 +212,21 @@ export class DefaultQueueManager implements QueueManager {
185212
environmentId: environment.id,
186213
});
187214

188-
return defaultQueueName;
215+
return { queueName: overriddenQueueName ?? defaultQueueName, taskTtl: undefined };
216+
}
217+
218+
// When queue is overridden, we only need TTL from the task (no queue join needed)
219+
if (overriddenQueueName) {
220+
const task = await this.prisma.backgroundWorkerTask.findFirst({
221+
where: {
222+
workerId: worker.id,
223+
runtimeEnvironmentId: environment.id,
224+
slug: taskId,
225+
},
226+
select: { ttl: true },
227+
});
228+
229+
return { queueName: overriddenQueueName, taskTtl: task?.ttl };
189230
}
190231

191232
const task = await this.prisma.backgroundWorkerTask.findFirst({
@@ -205,7 +246,7 @@ export class DefaultQueueManager implements QueueManager {
205246
environmentId: environment.id,
206247
});
207248

208-
return defaultQueueName;
249+
return { queueName: defaultQueueName, taskTtl: undefined };
209250
}
210251

211252
if (!task.queue) {
@@ -215,10 +256,10 @@ export class DefaultQueueManager implements QueueManager {
215256
queueConfig: task.queueConfig,
216257
});
217258

218-
return defaultQueueName;
259+
return { queueName: defaultQueueName, taskTtl: task.ttl };
219260
}
220261

221-
return task.queue.name ?? defaultQueueName;
262+
return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl };
222263
}
223264

224265
async validateQueueLimits(

apps/webapp/app/runEngine/services/triggerTask.server.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,6 @@ export class RunEngineTriggerTaskService {
191191
}
192192
}
193193

194-
const ttl =
195-
typeof body.options?.ttl === "number"
196-
? stringifyDuration(body.options?.ttl)
197-
: body.options?.ttl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined);
198-
199194
// Get parent run if specified
200195
const parentRun = body.options?.parentRunId
201196
? await this.prisma.taskRun.findFirst({
@@ -251,10 +246,23 @@ export class RunEngineTriggerTaskService {
251246
})
252247
: undefined;
253248

254-
const { queueName, lockedQueueId } = await this.queueConcern.resolveQueueProperties(
255-
triggerRequest,
256-
lockedToBackgroundWorker ?? undefined
257-
);
249+
const { queueName, lockedQueueId, taskTtl } =
250+
await this.queueConcern.resolveQueueProperties(
251+
triggerRequest,
252+
lockedToBackgroundWorker ?? undefined
253+
);
254+
255+
// Resolve TTL with precedence: per-trigger > task-level > dev default
256+
let ttl: string | undefined;
257+
258+
if (body.options?.ttl !== undefined) {
259+
ttl =
260+
typeof body.options.ttl === "number"
261+
? stringifyDuration(body.options.ttl)
262+
: body.options.ttl;
263+
} else {
264+
ttl = taskTtl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined);
265+
}
258266

259267
if (!options.skipChecks) {
260268
const queueSizeGuard = await this.queueConcern.validateQueueLimits(

apps/webapp/app/runEngine/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ export type QueueValidationResult =
4848
export type QueueProperties = {
4949
queueName: string;
5050
lockedQueueId?: string;
51+
taskTtl?: string | null;
5152
};
5253

5354
export type LockedBackgroundWorker = Pick<
@@ -61,7 +62,6 @@ export interface QueueManager {
6162
request: TriggerTaskRequest,
6263
lockedBackgroundWorker?: LockedBackgroundWorker
6364
): Promise<QueueProperties>;
64-
getQueueName(request: TriggerTaskRequest): Promise<string>;
6565
validateQueueLimits(
6666
env: AuthenticatedEnvironment,
6767
queueName: string,

apps/webapp/app/v3/services/createBackgroundWorker.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
QueueManifest,
77
TaskResource,
88
} from "@trigger.dev/core/v3";
9-
import { BackgroundWorkerId } from "@trigger.dev/core/v3/isomorphic";
9+
import { BackgroundWorkerId, stringifyDuration } from "@trigger.dev/core/v3/isomorphic";
1010
import type { BackgroundWorker, TaskQueue, TaskQueueType } from "@trigger.dev/database";
1111
import cronstrue from "cronstrue";
1212
import { $transaction, Prisma, PrismaClientOrTransaction } from "~/db.server";
@@ -286,6 +286,8 @@ async function createWorkerTask(
286286
triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD",
287287
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
288288
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
289+
ttl:
290+
typeof task.ttl === "number" ? stringifyDuration(task.ttl) ?? null : task.ttl ?? null,
289291
queueId: queue.id,
290292
payloadSchema: task.payloadSchema as any,
291293
},
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN IF NOT EXISTS "ttl" TEXT;

internal-packages/database/prisma/schema.prisma

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -648,6 +648,8 @@ model BackgroundWorkerTask {
648648
649649
maxDurationInSeconds Int?
650650
651+
ttl String?
652+
651653
triggerSource TaskTriggerSource @default(STANDARD)
652654
653655
payloadSchema Json?

packages/cli-v3/src/entryPoints/dev-index-worker.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@ if (typeof config.maxDuration === "number") {
132132
});
133133
}
134134

135+
// If the config has a TTL, we need to apply it to all tasks that don't have a TTL
136+
if (config.ttl !== undefined) {
137+
tasks = tasks.map((task) => {
138+
if (task.ttl === undefined) {
139+
return {
140+
...task,
141+
ttl: config.ttl,
142+
} satisfies TaskManifest;
143+
}
144+
145+
return task;
146+
});
147+
}
148+
135149
// If the config has a machine preset, we need to apply it to all tasks that don't have a machine preset
136150
if (typeof config.machine === "string") {
137151
tasks = tasks.map((task) => {

packages/cli-v3/src/entryPoints/managed-index-worker.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,20 @@ if (typeof config.maxDuration === "number") {
132132
});
133133
}
134134

135+
// If the config has a TTL, we need to apply it to all tasks that don't have a TTL
136+
if (config.ttl !== undefined) {
137+
tasks = tasks.map((task) => {
138+
if (task.ttl === undefined) {
139+
return {
140+
...task,
141+
ttl: config.ttl,
142+
} satisfies TaskManifest;
143+
}
144+
145+
return task;
146+
});
147+
}
148+
135149
// If the config has a machine preset, we need to apply it to all tasks that don't have a machine preset
136150
if (typeof config.machine === "string") {
137151
tasks = tasks.map((task) => {

0 commit comments

Comments
 (0)