Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/quiet-dogs-fly.md
Original file line number Diff line number Diff line change
@@ -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
11 changes: 11 additions & 0 deletions apps/webapp/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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.
- 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.
60 changes: 54 additions & 6 deletions apps/webapp/app/runEngine/concerns/queues.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export class DefaultQueueManager implements QueueManager {
): Promise<QueueProperties> {
let queueName: string;
let lockedQueueId: string | undefined;
let taskTtl: string | null | undefined;

// Determine queue name based on lockToVersion and provided options
if (lockedBackgroundWorker) {
Expand All @@ -98,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({
Expand Down Expand Up @@ -134,6 +142,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
Expand All @@ -145,7 +154,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
Expand All @@ -161,17 +172,27 @@ export class DefaultQueueManager implements QueueManager {
return {
queueName,
lockedQueueId,
taskTtl,
};
}

async getQueueName(request: TriggerTaskRequest): Promise<string> {
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;
// Still need task TTL even when queue is overridden
const taskTtl = await this.getTaskTtlForEnvironment(taskId, environment);
return { queueName, taskTtl };
}

const defaultQueueName = `task/${taskId}`;
Expand All @@ -185,7 +206,7 @@ export class DefaultQueueManager implements QueueManager {
environmentId: environment.id,
});

return defaultQueueName;
return { queueName: defaultQueueName };
}

const task = await this.prisma.backgroundWorkerTask.findFirst({
Expand All @@ -205,7 +226,7 @@ export class DefaultQueueManager implements QueueManager {
environmentId: environment.id,
});

return defaultQueueName;
return { queueName: defaultQueueName };
}

if (!task.queue) {
Expand All @@ -215,10 +236,37 @@ 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 };
}

private async getTaskTtl(
taskId: string,
environmentId: string,
workerId: string
): Promise<string | null | undefined> {
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<string | null | undefined> {
const worker = await findCurrentWorkerFromEnvironment(environment, this.prisma);
if (!worker) {
return undefined;
}
return this.getTaskTtl(taskId, environment.id, worker.id);
}

async validateQueueLimits(
Expand Down
26 changes: 17 additions & 9 deletions apps/webapp/app/runEngine/services/triggerTask.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,11 +190,6 @@ export class RunEngineTriggerTaskService {
}
}

const ttl =
typeof body.options?.ttl === "number"
? stringifyDuration(body.options?.ttl)
: body.options?.ttl ?? (environment.type === "DEVELOPMENT" ? "10m" : undefined);

// Get parent run if specified
const parentRun = body.options?.parentRunId
? await this.prisma.taskRun.findFirst({
Expand Down Expand Up @@ -250,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(
Expand Down
1 change: 1 addition & 0 deletions apps/webapp/app/runEngine/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export type QueueValidationResult =
export type QueueProperties = {
queueName: string;
lockedQueueId?: string;
taskTtl?: string | null;
};

export type LockedBackgroundWorker = Pick<
Expand Down
4 changes: 3 additions & 1 deletion apps/webapp/app/v3/services/createBackgroundWorker.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN IF NOT EXISTS "ttl" TEXT;
2 changes: 2 additions & 0 deletions internal-packages/database/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,8 @@ model BackgroundWorkerTask {

maxDurationInSeconds Int?

ttl String?

triggerSource TaskTriggerSource @default(STANDARD)

payloadSchema Json?
Expand Down
14 changes: 14 additions & 0 deletions packages/cli-v3/src/entryPoints/dev-index-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
14 changes: 14 additions & 0 deletions packages/cli-v3/src/entryPoints/managed-index-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/v3/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/v3/schemas/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};

Expand Down
23 changes: 23 additions & 0 deletions packages/core/src/v3/types/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/trigger-sdk/src/v3/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading