Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions .changeset/duplicate-task-ids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/core": patch
"trigger.dev": patch
---

`dev` and `deploy` now fail with a clear error when two tasks are defined with the same id, including across different task types (e.g. a scheduled task and a regular task sharing an id). Previously the second definition silently overwrote the first, so one of the tasks would vanish with no warning. Task ids are detected as duplicates during indexing (naming each offending id and the files it was found in), and the same rule is enforced server-side when the background worker is registered.
17 changes: 17 additions & 0 deletions apps/webapp/app/v3/services/createBackgroundWorker.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { engine } from "../runEngine.server";
import { scheduleEngine } from "../scheduleEngine.server";

import { stripBackgroundWorkerMetadataForStorage } from "./stripBackgroundWorkerMetadataForStorage.server";
import { assertNoDuplicateTaskIds } from "./duplicateTaskIds.server";
export { stripBackgroundWorkerMetadataForStorage };

export class CreateBackgroundWorkerService extends BaseService {
Expand Down Expand Up @@ -151,6 +152,15 @@ export class CreateBackgroundWorkerService extends BaseService {
);

if (resourcesError) {
if (resourcesError instanceof ServiceValidationError) {
// Customer-facing config error (e.g. duplicate task ids). Surface the
// real message to the client via the rethrow.
logger.warn("Error creating worker resources", {
error: resourcesError.message,
});
throw resourcesError;
}

logger.error("Error creating worker resources", {
error: resourcesError,
backgroundWorker,
Expand Down Expand Up @@ -279,6 +289,13 @@ export async function createWorkerResources(
prisma: PrismaClientOrTransaction,
tasksToBackgroundFiles?: Map<string, string>
): Promise<TaskMetadataEntry[]> {
// Defense-in-depth against two tasks sharing an id (across all task types,
// e.g. a schedule and a regular task). Note: the CLI's resource catalog keys
// tasks by id and overwrites collisions, so duplicates are normally already
// collapsed before reaching here — this guards against any client that sends
// an un-deduplicated task list.
assertNoDuplicateTaskIds(metadata.tasks);

// Create the queues
const queues = await createWorkerQueues(metadata, worker, environment, prisma);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,17 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
);

if (resourcesError) {
if (resourcesError instanceof ServiceValidationError) {
// Customer-facing config error (e.g. duplicate task ids). Surface the
// real message to the client via the rethrow.
logger.warn("Error creating background worker resources", {
error: resourcesError.message,
});

await this.#failBackgroundWorkerDeployment(deployment, resourcesError);
throw resourcesError;
}

logger.error("Error creating background worker resources", {
error: resourcesError,
});
Expand Down
56 changes: 56 additions & 0 deletions apps/webapp/app/v3/services/duplicateTaskIds.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { ServiceValidationError } from "./common.server";

type TaskIdResource = {
id: string;
filePath?: string;
exportName?: string;
};

/**
* Returns the set of task ids that are defined more than once. All task types
* (regular tasks, scheduled tasks, agents, etc.) share a single id namespace,
* so a schedule and a regular task that use the same id count as a duplicate.
*/
export function findDuplicateTaskIds(tasks: Array<TaskIdResource>): string[] {
const seen = new Set<string>();
const duplicates = new Set<string>();

for (const task of tasks) {
if (seen.has(task.id)) {
duplicates.add(task.id);
} else {
seen.add(task.id);
}
}

return Array.from(duplicates);
}

/**
* Throws a customer-facing {@link ServiceValidationError} (HTTP 400) if any
* task id is defined more than once, naming each offending id and the files it
* was found in.
*/
export function assertNoDuplicateTaskIds(tasks: Array<TaskIdResource>): void {
const duplicateTaskIds = findDuplicateTaskIds(tasks);

if (duplicateTaskIds.length === 0) {
return;
}

const details = duplicateTaskIds
.map((id) => {
const locations = tasks
.filter((task) => task.id === id)
.map((task) => task.filePath ?? "unknown file")
.join(", ");

return `"${id}" (defined in ${locations})`;
})
.join("; ");

throw new ServiceValidationError(
`Duplicate task ids detected: ${details}. Each task must have a unique id across all task types (including scheduled tasks). Please rename one of them.`,
400
);
}
55 changes: 55 additions & 0 deletions apps/webapp/test/duplicateTaskIds.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { ServiceValidationError } from "~/v3/services/common.server";
import { assertNoDuplicateTaskIds } from "~/v3/services/duplicateTaskIds.server";

function task(partial: { id: string; filePath?: string; exportName?: string; triggerSource?: string }) {
return {
filePath: "src/trigger/example.ts",
exportName: "exampleTask",
...partial,
} as any;
}

describe("assertNoDuplicateTaskIds", () => {
it("does not throw when all task ids are unique", () => {
const tasks = [task({ id: "a" }), task({ id: "b" }), task({ id: "c" })];

expect(() => assertNoDuplicateTaskIds(tasks)).not.toThrow();
});

it("throws a ServiceValidationError when a task id is duplicated", () => {
const tasks = [task({ id: "a" }), task({ id: "a" })];

expect(() => assertNoDuplicateTaskIds(tasks)).toThrow(ServiceValidationError);
});

it("reports a 400 and names the duplicate id and its files", () => {
const tasks = [
task({ id: "report", filePath: "src/trigger/report.ts" }),
task({ id: "report", filePath: "src/trigger/scheduled.ts" }),
];

let error: unknown;
try {
assertNoDuplicateTaskIds(tasks);
} catch (e) {
error = e;
}

expect(error).toBeInstanceOf(ServiceValidationError);
const validationError = error as ServiceValidationError;
expect(validationError.status).toBe(400);
expect(validationError.message).toContain("report");
expect(validationError.message).toContain("src/trigger/report.ts");
expect(validationError.message).toContain("src/trigger/scheduled.ts");
});

it("detects duplicates across different task types (a schedule and a regular task sharing an id)", () => {
const tasks = [
task({ id: "report", triggerSource: undefined, filePath: "src/trigger/report.ts" }),
task({ id: "report", triggerSource: "schedule", filePath: "src/trigger/scheduled.ts" }),
];

expect(() => assertNoDuplicateTaskIds(tasks)).toThrow(ServiceValidationError);
});
});
22 changes: 22 additions & 0 deletions packages/cli-v3/src/dev/devOutput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
import {
createTaskMetadataFailedErrorStack,
DuplicateTaskIdsError,
TaskIndexingImportError,
TaskMetadataParseError,
} from "@trigger.dev/core/v3/errors";
Expand Down Expand Up @@ -130,6 +131,27 @@ export function startDevOutput(options: DevOutputOptions) {
project: config.project,
query: `Could not parse task metadata:\n ${errorStack}`,
});
} else if (error instanceof DuplicateTaskIdsError) {
const body = error.collisions
.map(({ id, filePaths }) => {
const distinct = Array.from(new Set(filePaths));

return distinct.length === 1
? `${chalkTask(id)} was defined more than once in ${distinct[0]}`
: `${chalkTask(id)} was defined in:\n${distinct.map((f) => ` ${f}`).join("\n")}`;
})
.join("\n\n");

prettyError(
"Duplicate task ids detected",
`${body}\n\nTask ids must be unique across your project (including scheduled tasks). Please rename one of them.`,
cliLink("View the task docs", "https://trigger.dev/docs/tasks/overview")
);
aiHelpLink({
dashboardUrl,
project: config.project,
query: `Duplicate task ids: ${error.collisions.map((c) => c.id).join(", ")}`,
});
} else {
const errorText = error instanceof Error ? error.message : "Unknown error";
const stack = error instanceof Error ? error.stack : undefined;
Expand Down
53 changes: 8 additions & 45 deletions packages/cli-v3/src/dev/devSupervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
CreateBackgroundWorkerRequestBody,
DevConfigResponseBody,
SemanticInternalAttributes,
TaskManifest,
WorkerManifest,
} from "@trigger.dev/core/v3";
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
Expand All @@ -20,7 +19,7 @@ import { resolveSourceFiles } from "../utilities/sourceFiles.js";
import { BackgroundWorker } from "./backgroundWorker.js";
import { copySkillFolders } from "../build/bundleSkills.js";
import { WorkerRuntime } from "./workerRuntime.js";
import { chalkTask, cliLink, prettyError } from "../utilities/cliOutput.js";
import { cliLink, prettyError } from "../utilities/cliOutput.js";
import { DevRunController } from "../entryPoints/dev-run-controller.js";
import { io, Socket } from "socket.io-client";
import {
Expand Down Expand Up @@ -841,38 +840,24 @@ class DevSupervisor implements WorkerRuntime {
}
}

type ValidationIssue =
| {
type: "duplicateTaskId";
duplicationTaskIds: string[];
}
| {
type: "noTasksDefined";
};
type ValidationIssue = {
type: "noTasksDefined";
};

// Duplicate task ids (including across task types, e.g. a schedule and a
// regular task sharing an id) are enforced server-side when the background
// worker is registered, so both `dev` and `deploy` surface a single,
// authoritative error from the backend rather than a separate client check.
function validateWorkerManifest(manifest: WorkerManifest): ValidationIssue | undefined {
const issues: ValidationIssue[] = [];

if (!manifest.tasks || manifest.tasks.length === 0) {
return { type: "noTasksDefined" };
}

// Check for any duplicate task ids
const taskIds = manifest.tasks.map((task) => task.id);
const duplicateTaskIds = taskIds.filter((id, index) => taskIds.indexOf(id) !== index);

if (duplicateTaskIds.length > 0) {
return { type: "duplicateTaskId", duplicationTaskIds: duplicateTaskIds };
}

return undefined;
}

function generationValidationIssueHeader(issue: ValidationIssue) {
switch (issue.type) {
case "duplicateTaskId": {
return `Duplicate task ids detected`;
}
case "noTasksDefined": {
return `No tasks exported from your trigger files`;
}
Expand All @@ -881,9 +866,6 @@ function generationValidationIssueHeader(issue: ValidationIssue) {

function generateValidationIssueFooter(issue: ValidationIssue) {
switch (issue.type) {
case "duplicateTaskId": {
return cliLink("View the task docs", "https://trigger.dev/docs/tasks/overview");
}
case "noTasksDefined": {
return cliLink("View the task docs", "https://trigger.dev/docs/tasks/overview");
}
Expand All @@ -896,9 +878,6 @@ function generateValidationIssueMessage(
buildManifest: BuildManifest
) {
switch (issue.type) {
case "duplicateTaskId": {
return createDuplicateTaskIdOutputErrorMessage(issue.duplicationTaskIds, manifest.tasks);
}
case "noTasksDefined": {
return `
Files:
Expand All @@ -923,19 +902,3 @@ function generateValidationIssueMessage(
}
}

function createDuplicateTaskIdOutputErrorMessage(
duplicateTaskIds: Array<string>,
tasks: Array<TaskManifest>
) {
const duplicateTable = duplicateTaskIds
.map((id) => {
const $tasks = tasks.filter((task) => task.id === id);

return `\n\n${chalkTask(id)} was found in:${tasks
.map((task) => `\n${task.filePath} -> ${task.exportName}`)
.join("")}`;
})
.join("");

return `Duplicate ${chalkTask("task id")} detected:${duplicateTable}`;
}
10 changes: 10 additions & 0 deletions packages/cli-v3/src/entryPoints/dev-index-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { sendMessageInCatalog, ZodSchemaParsedError } from "@trigger.dev/core/v3
import { readFile } from "node:fs/promises";
import sourceMapSupport from "source-map-support";
import { registerResources } from "../indexing/registerResources.js";
import { reportTaskIdCollisions } from "../indexing/reportTaskIdCollisions.js";
import { env } from "std-env";
import { normalizeImportPath } from "../utilities/normalizeImportPath.js";
import { detectRuntimeVersion } from "@trigger.dev/core/v3/build";
Expand Down Expand Up @@ -117,6 +118,15 @@ async function bootstrap() {

const { buildManifest, importErrors, config, timings } = await bootstrap();

// Fail indexing if two task definitions share an id (across files and task
// types). The catalog keys tasks by id, so without this the second definition
// would silently overwrite the first.
if (await reportTaskIdCollisions(safeSend)) {
// Give the message time to flush before the parent kills the worker.
await new Promise<void>((resolve) => setTimeout(resolve, 10));
process.exit(0);
}

let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests());

// If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings
Expand Down
10 changes: 10 additions & 0 deletions packages/cli-v3/src/entryPoints/managed-index-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { sendMessageInCatalog, ZodSchemaParsedError } from "@trigger.dev/core/v3
import { readFile } from "node:fs/promises";
import sourceMapSupport from "source-map-support";
import { registerResources } from "../indexing/registerResources.js";
import { reportTaskIdCollisions } from "../indexing/reportTaskIdCollisions.js";
import { env } from "std-env";
import { normalizeImportPath } from "../utilities/normalizeImportPath.js";
import { detectRuntimeVersion } from "@trigger.dev/core/v3/build";
Expand Down Expand Up @@ -111,6 +112,15 @@ async function bootstrap() {

const { buildManifest, importErrors, config, timings } = await bootstrap();

// Fail indexing if two task definitions share an id (across files and task
// types). The catalog keys tasks by id, so without this the second definition
// would silently overwrite the first.
if (await reportTaskIdCollisions(safeSend)) {
// Give the message time to flush before the parent kills the worker.
await new Promise<void>((resolve) => setTimeout(resolve, 10));
process.exit(0);
}

let tasks = await convertSchemasToJsonSchemas(resourceCatalog.listTaskManifests());

// If the config has retry defaults, we need to apply them to all tasks that don't have any retry settings
Expand Down
8 changes: 8 additions & 0 deletions packages/cli-v3/src/indexing/indexWorkerManifest.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { execPathForRuntime } from "@trigger.dev/core/v3/build";
import {
DuplicateTaskIdsError,
TaskIndexingImportError,
TaskMetadataParseError,
UncaughtExceptionError,
Expand Down Expand Up @@ -89,6 +90,13 @@ export async function indexWorkerManifest({
child.kill("SIGKILL");
break;
}
case "TASKS_FAILED_TO_INDEX": {
clearTimeout(timeout);
resolved = true;
reject(new DuplicateTaskIdsError(message.payload.collisions));
child.kill("SIGKILL");
break;
}
case "UNCAUGHT_EXCEPTION": {
clearTimeout(timeout);
resolved = true;
Expand Down
Loading
Loading