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/quick-plums-tan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@trigger.dev/sdk": patch
"@trigger.dev/core": patch
---

Added support for idempotency reset
51 changes: 51 additions & 0 deletions apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { json } from "@remix-run/server-runtime";
import { ServiceValidationError } from "~/v3/services/baseService.server";
import { z } from "zod";
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { logger } from "~/services/logger.server";

const ParamsSchema = z.object({
key: z.string(),
});

const BodySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});

export const { action } = createActionApiRoute(
{
params: ParamsSchema,
body: BodySchema,
allowJWT: true,
corsStrategy: "all",
authorization: {
action: "write",
resource: () => ({}),
superScopes: ["write:runs", "admin"],
},
},
async ({ params, body, authentication }) => {
const service = new ResetIdempotencyKeyService();

try {
const result = await service.call(
params.key,
body.taskIdentifier,
authentication.environment
);
return json(result, { status: 200 });
} catch (error) {
if (error instanceof ServiceValidationError) {
return json({ error: error.message }, { status: error.status ?? 400 });
}

logger.error("Failed to reset idempotency key via API", {
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : String(error),
});

return json({ error: "Internal Server Error" }, { status: 500 });
}

}
);
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { parse } from "@conform-to/zod";
import { type ActionFunction, json } from "@remix-run/node";
import { z } from "zod";
import { prisma } from "~/db.server";
import { jsonWithErrorMessage } from "~/models/message.server";
import { logger } from "~/services/logger.server";
import { requireUserId } from "~/services/session.server";
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
import { v3RunParamsSchema } from "~/utils/pathBuilder";

export const resetIdempotencyKeySchema = z.object({
taskIdentifier: z.string().min(1, "Task identifier is required"),
});

export const action: ActionFunction = async ({ request, params }) => {
const userId = await requireUserId(request);
const { projectParam, organizationSlug, envParam, runParam } =
v3RunParamsSchema.parse(params);

const formData = await request.formData();
const submission = parse(formData, { schema: resetIdempotencyKeySchema });

if (!submission.value) {
return json(submission);
}

try {
const { taskIdentifier } = submission.value;

const taskRun = await prisma.taskRun.findFirst({
where: {
friendlyId: runParam,
project: {
slug: projectParam,
organization: {
slug: organizationSlug,
members: {
some: {
userId,
},
},
},
},
runtimeEnvironment: {
slug: envParam,
},
},
select: {
id: true,
idempotencyKey: true,
taskIdentifier: true,
runtimeEnvironmentId: true,
},
});

if (!taskRun) {
submission.error = { runParam: ["Run not found"] };
return json(submission);
}

if (!taskRun.idempotencyKey) {
return jsonWithErrorMessage(
submission,
request,
"This run does not have an idempotency key"
);
}

if (taskRun.taskIdentifier !== taskIdentifier) {
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
return json(submission);
}

const environment = await prisma.runtimeEnvironment.findUnique({
where: {
id: taskRun.runtimeEnvironmentId,
},
include: {
project: {
include: {
organization: true,
},
},
},
});

if (!environment) {
return jsonWithErrorMessage(
submission,
request,
"Environment not found"
);
}

const service = new ResetIdempotencyKeyService();

await service.call(taskRun.idempotencyKey, taskIdentifier, {
...environment,
organizationId: environment.project.organizationId,
organization: environment.project.organization,
});

return json({ success: true });
} catch (error) {
if (error instanceof Error) {
logger.error("Failed to reset idempotency key", {
error: {
name: error.name,
message: error.message,
stack: error.stack,
},
});
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${error.message}`
);
} else {
logger.error("Failed to reset idempotency key", { error });
return jsonWithErrorMessage(
submission,
request,
`Failed to reset idempotency key: ${JSON.stringify(error)}`
);
}
}
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ArrowPathIcon,
CheckIcon,
CloudArrowDownIcon,
EnvelopeIcon,
Expand Down Expand Up @@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
import { Paragraph } from "~/components/primitives/Paragraph";
import * as Property from "~/components/primitives/PropertyTable";
import { Spinner } from "~/components/primitives/Spinner";
import { toast } from "sonner";
import {
Table,
TableBody,
Expand All @@ -40,6 +42,7 @@ import {
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
import { TextLink } from "~/components/primitives/TextLink";
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
import { ToastUI } from "~/components/primitives/Toast";
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
import { RunIcon } from "~/components/runs/v3/RunIcon";
Expand Down Expand Up @@ -69,6 +72,7 @@ import {
v3BatchPath,
v3DeploymentVersionPath,
v3RunDownloadLogsPath,
v3RunIdempotencyKeyResetPath,
v3RunPath,
v3RunRedirectPath,
v3RunSpanPath,
Expand All @@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
import { requireUserId } from "~/services/session.server";
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";

export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const userId = await requireUserId(request);
Expand Down Expand Up @@ -293,6 +298,28 @@ function RunBody({
const isAdmin = useHasAdminAccess();
const { value, replace } = useSearchParams();
const tab = value("tab");
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();

// Handle toast messages from the reset action
useEffect(() => {
if (resetFetcher.data && resetFetcher.state === "idle") {
// Check if the response indicates success
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
toast.custom(
(t) => (
<ToastUI
variant="success"
message="Idempotency key reset successfully"
t={t as string}
/>
),
{
duration: 5000,
}
);
}
}
}, [resetFetcher.data, resetFetcher.state]);

return (
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
Expand Down Expand Up @@ -543,17 +570,37 @@ function RunBody({
<Property.Item>
<Property.Label>Idempotency</Property.Label>
<Property.Value>
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
"–"
<div className="flex items-start justify-between gap-2">
<div className="flex-1">
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
{run.idempotencyKey && (
<div>
Expires:{" "}
{run.idempotencyKeyExpiresAt ? (
<DateTime date={run.idempotencyKeyExpiresAt} />
) : (
"–"
)}
</div>
)}
</div>
)}
{run.idempotencyKey && (
<resetFetcher.Form
method="post"
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
>
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
<Button
type="submit"
variant="minimal/small"
LeadingIcon={ArrowPathIcon}
disabled={resetFetcher.state === "submitting"}
>
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
</Button>
</resetFetcher.Form>
)}
</div>
</Property.Value>
</Property.Item>
<Property.Item>
Expand Down
11 changes: 11 additions & 0 deletions apps/webapp/app/utils/pathBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@ export function v3RunStreamingPath(
return `${v3RunPath(organization, project, environment, run)}/stream`;
}

export function v3RunIdempotencyKeyResetPath(
organization: OrgForPath,
project: ProjectForPath,
environment: EnvironmentForPath,
run: v3RunForPath
) {
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
project
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
}

export function v3SchedulesPath(
organization: OrgForPath,
project: ProjectForPath,
Expand Down
36 changes: 36 additions & 0 deletions apps/webapp/app/v3/services/resetIdempotencyKey.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { BaseService, ServiceValidationError } from "./baseService.server";
import { logger } from "~/services/logger.server";

export class ResetIdempotencyKeyService extends BaseService {
public async call(
idempotencyKey: string,
taskIdentifier: string,
authenticatedEnv: AuthenticatedEnvironment
): Promise<{ id: string }> {
const { count } = await this._prisma.taskRun.updateMany({
where: {
idempotencyKey,
taskIdentifier,
runtimeEnvironmentId: authenticatedEnv.id,
},
data: {
idempotencyKey: null,
idempotencyKeyExpiresAt: null,
},
});

if (count === 0) {
throw new ServiceValidationError(
`No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`,
404
);
}

logger.info(
`Reset idempotency key: ${idempotencyKey} for task: ${taskIdentifier} in env: ${authenticatedEnv.id}, affected ${count} run(s)`
);

return { id: idempotencyKey };
}
}
23 changes: 23 additions & 0 deletions docs/idempotency.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,29 @@ function hash(payload: any): string {
}
```

## Resetting idempotency keys

You can reset an idempotency key to clear it from all associated runs. This is useful if you need to allow a task to be triggered again with the same idempotency key.

When you reset an idempotency key, it will be cleared for all runs that match both the task identifier and the idempotency key in the current environment. This allows you to trigger the task again with the same key.

```ts
import { idempotencyKeys } from "@trigger.dev/sdk";

// Reset an idempotency key for a specific task
await idempotencyKeys.reset("my-task", "my-idempotency-key");
```

The `reset` function requires both parameters:
- `taskIdentifier`: The identifier of the task (e.g., `"my-task"`)
- `idempotencyKey`: The idempotency key to reset

After resetting, any subsequent triggers with the same idempotency key will create new task runs instead of returning the existing ones.

<Note>
Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.
</Note>

## Important notes

Idempotency keys, even the ones scoped globally, are actually scoped to the task and the environment. This means that you cannot collide with keys from other environments (e.g. dev will never collide with prod), or to other projects and orgs.
Expand Down
Loading