Skip to content

Commit dda38b7

Browse files
committed
feat: add support for resetting idempotency keys from run detail view
- Add new action route for resetting idempotency keys via UI - Add reset button in Idempotency section of run detail view - Added API and SDK for resetting imdepotency - Updated docs page for this feature
1 parent b71bf89 commit dda38b7

File tree

10 files changed

+343
-10
lines changed

10 files changed

+343
-10
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { z } from "zod";
3+
import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server";
4+
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
5+
6+
const ParamsSchema = z.object({
7+
key: z.string(),
8+
});
9+
10+
const BodySchema = z.object({
11+
taskIdentifier: z.string().min(1, "Task identifier is required"),
12+
});
13+
14+
export const { action } = createActionApiRoute(
15+
{
16+
params: ParamsSchema,
17+
body: BodySchema,
18+
allowJWT: true,
19+
corsStrategy: "all",
20+
authorization: {
21+
action: "write",
22+
resource: () => ({}),
23+
superScopes: ["write:runs", "admin"],
24+
},
25+
},
26+
async ({ params, body, authentication }) => {
27+
const service = new ResetIdempotencyKeyService();
28+
29+
try {
30+
const result = await service.call(params.key, body.taskIdentifier, authentication.environment);
31+
return json(result, { status: 200 });
32+
} catch (error) {
33+
if (error instanceof Error) {
34+
return json({ error: error.message }, { status: 404 });
35+
}
36+
return json({ error: "Internal Server Error" }, { status: 500 });
37+
}
38+
}
39+
);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import { parse } from "@conform-to/zod";
2+
import { type ActionFunction, json } from "@remix-run/node";
3+
import { z } from "zod";
4+
import { prisma } from "~/db.server";
5+
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
6+
import { logger } from "~/services/logger.server";
7+
import { requireUserId } from "~/services/session.server";
8+
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
9+
import { v3RunParamsSchema } from "~/utils/pathBuilder";
10+
import { authenticateApiRequest } from "~/services/apiAuth.server";
11+
import { environment } from "effect/Differ";
12+
13+
export const resetIdempotencyKeySchema = z.object({
14+
taskIdentifier: z.string().min(1, "Task identifier is required"),
15+
});
16+
17+
export const action: ActionFunction = async ({ request, params }) => {
18+
const userId = await requireUserId(request);
19+
const { projectParam, organizationSlug, envParam, runParam } =
20+
v3RunParamsSchema.parse(params);
21+
22+
const formData = await request.formData();
23+
const submission = parse(formData, { schema: resetIdempotencyKeySchema });
24+
25+
if (!submission.value) {
26+
return json(submission);
27+
}
28+
29+
try {
30+
const { taskIdentifier } = submission.value;
31+
32+
const taskRun = await prisma.taskRun.findFirst({
33+
where: {
34+
friendlyId: runParam,
35+
project: {
36+
slug: projectParam,
37+
organization: {
38+
slug: organizationSlug,
39+
members: {
40+
some: {
41+
userId,
42+
},
43+
},
44+
},
45+
},
46+
runtimeEnvironment: {
47+
slug: envParam,
48+
},
49+
},
50+
select: {
51+
id: true,
52+
idempotencyKey: true,
53+
taskIdentifier: true,
54+
runtimeEnvironmentId: true,
55+
},
56+
});
57+
58+
if (!taskRun) {
59+
submission.error = { runParam: ["Run not found"] };
60+
return json(submission);
61+
}
62+
63+
if (!taskRun.idempotencyKey) {
64+
return jsonWithErrorMessage(
65+
submission,
66+
request,
67+
"This run does not have an idempotency key"
68+
);
69+
}
70+
71+
if (taskRun.taskIdentifier !== taskIdentifier) {
72+
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
73+
return json(submission);
74+
}
75+
76+
const environment = await prisma.runtimeEnvironment.findUnique({
77+
where: {
78+
id: taskRun.runtimeEnvironmentId,
79+
},
80+
include: {
81+
project: {
82+
include: {
83+
organization: true,
84+
},
85+
},
86+
},
87+
});
88+
89+
if (!environment) {
90+
return jsonWithErrorMessage(
91+
submission,
92+
request,
93+
"Environment not found"
94+
);
95+
}
96+
97+
const service = new ResetIdempotencyKeyService();
98+
99+
await service.call(taskRun.idempotencyKey, taskIdentifier, {
100+
...environment,
101+
organizationId: environment.project.organizationId,
102+
organization: environment.project.organization,
103+
});
104+
105+
return jsonWithSuccessMessage(
106+
{ success: true },
107+
request,
108+
"Idempotency key reset successfully"
109+
);
110+
} catch (error) {
111+
if (error instanceof Error) {
112+
logger.error("Failed to reset idempotency key", {
113+
error: {
114+
name: error.name,
115+
message: error.message,
116+
stack: error.stack,
117+
},
118+
});
119+
return jsonWithErrorMessage(
120+
submission,
121+
request,
122+
`Failed to reset idempotency key: ${error.message}`
123+
);
124+
} else {
125+
logger.error("Failed to reset idempotency key", { error });
126+
return jsonWithErrorMessage(
127+
submission,
128+
request,
129+
`Failed to reset idempotency key: ${JSON.stringify(error)}`
130+
);
131+
}
132+
}
133+
};

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route.tsx

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import {
2+
ArrowPathIcon,
23
CheckIcon,
34
CloudArrowDownIcon,
45
EnvelopeIcon,
@@ -29,6 +30,7 @@ import { Header2, Header3 } from "~/components/primitives/Headers";
2930
import { Paragraph } from "~/components/primitives/Paragraph";
3031
import * as Property from "~/components/primitives/PropertyTable";
3132
import { Spinner } from "~/components/primitives/Spinner";
33+
import { toast } from "sonner";
3234
import {
3335
Table,
3436
TableBody,
@@ -40,6 +42,7 @@ import {
4042
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
4143
import { TextLink } from "~/components/primitives/TextLink";
4244
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
45+
import { ToastUI } from "~/components/primitives/Toast";
4346
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
4447
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
4548
import { RunIcon } from "~/components/runs/v3/RunIcon";
@@ -69,6 +72,7 @@ import {
6972
v3BatchPath,
7073
v3DeploymentVersionPath,
7174
v3RunDownloadLogsPath,
75+
v3RunIdempotencyKeyResetPath,
7276
v3RunPath,
7377
v3RunRedirectPath,
7478
v3RunSpanPath,
@@ -81,6 +85,7 @@ import { CompleteWaitpointForm } from "../resources.orgs.$organizationSlug.proje
8185
import { requireUserId } from "~/services/session.server";
8286
import type { SpanOverride } from "~/v3/eventRepository/eventRepository.types";
8387
import { RealtimeStreamViewer } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
88+
import { action as resetIdempotencyKeyAction } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset";
8489

8590
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
8691
const userId = await requireUserId(request);
@@ -293,6 +298,28 @@ function RunBody({
293298
const isAdmin = useHasAdminAccess();
294299
const { value, replace } = useSearchParams();
295300
const tab = value("tab");
301+
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
302+
303+
// Handle toast messages from the reset action
304+
useEffect(() => {
305+
if (resetFetcher.data && resetFetcher.state === "idle") {
306+
// Check if the response indicates success
307+
if (resetFetcher.data && typeof resetFetcher.data === "object" && "success" in resetFetcher.data && resetFetcher.data.success === true) {
308+
toast.custom(
309+
(t) => (
310+
<ToastUI
311+
variant="success"
312+
message="Idempotency key reset successfully"
313+
t={t as string}
314+
/>
315+
),
316+
{
317+
duration: 5000,
318+
}
319+
);
320+
}
321+
}
322+
}, [resetFetcher.data, resetFetcher.state]);
296323

297324
return (
298325
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_3.25rem] overflow-hidden bg-background-bright">
@@ -543,17 +570,37 @@ function RunBody({
543570
<Property.Item>
544571
<Property.Label>Idempotency</Property.Label>
545572
<Property.Value>
546-
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
547-
{run.idempotencyKey && (
548-
<div>
549-
Expires:{" "}
550-
{run.idempotencyKeyExpiresAt ? (
551-
<DateTime date={run.idempotencyKeyExpiresAt} />
552-
) : (
553-
"–"
573+
<div className="flex items-start justify-between gap-2">
574+
<div className="flex-1">
575+
<div className="break-all">{run.idempotencyKey ? run.idempotencyKey : "–"}</div>
576+
{run.idempotencyKey && (
577+
<div>
578+
Expires:{" "}
579+
{run.idempotencyKeyExpiresAt ? (
580+
<DateTime date={run.idempotencyKeyExpiresAt} />
581+
) : (
582+
"–"
583+
)}
584+
</div>
554585
)}
555586
</div>
556-
)}
587+
{run.idempotencyKey && (
588+
<resetFetcher.Form
589+
method="post"
590+
action={v3RunIdempotencyKeyResetPath(organization, project, environment, { friendlyId: runParam })}
591+
>
592+
<input type="hidden" name="taskIdentifier" value={run.taskIdentifier} />
593+
<Button
594+
type="submit"
595+
variant="minimal/small"
596+
LeadingIcon={ArrowPathIcon}
597+
disabled={resetFetcher.state === "submitting"}
598+
>
599+
{resetFetcher.state === "submitting" ? "Resetting..." : "Reset"}
600+
</Button>
601+
</resetFetcher.Form>
602+
)}
603+
</div>
557604
</Property.Value>
558605
</Property.Item>
559606
<Property.Item>

apps/webapp/app/utils/pathBuilder.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,17 @@ export function v3RunStreamingPath(
324324
return `${v3RunPath(organization, project, environment, run)}/stream`;
325325
}
326326

327+
export function v3RunIdempotencyKeyResetPath(
328+
organization: OrgForPath,
329+
project: ProjectForPath,
330+
environment: EnvironmentForPath,
331+
run: v3RunForPath
332+
) {
333+
return `/resources/orgs/${organizationParam(organization)}/projects/${projectParam(
334+
project
335+
)}/env/${environmentParam(environment)}/runs/${run.friendlyId}/idempotencyKey/reset`;
336+
}
337+
327338
export function v3SchedulesPath(
328339
organization: OrgForPath,
329340
project: ProjectForPath,
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
2+
import { BaseService, ServiceValidationError } from "./baseService.server";
3+
4+
export class ResetIdempotencyKeyService extends BaseService {
5+
public async call(
6+
idempotencyKey: string,
7+
taskIdentifier: string,
8+
authenticatedEnv: AuthenticatedEnvironment
9+
): Promise<{ id: string }> {
10+
// Find all runs with this idempotency key and task identifier in the authenticated environment
11+
const runs = await this._prisma.taskRun.findMany({
12+
where: {
13+
idempotencyKey,
14+
taskIdentifier,
15+
runtimeEnvironmentId: authenticatedEnv.id,
16+
},
17+
select: {
18+
id: true,
19+
},
20+
});
21+
22+
if (runs.length === 0) {
23+
throw new ServiceValidationError(
24+
`No runs found with idempotency key: ${idempotencyKey} and task: ${taskIdentifier}`,
25+
404
26+
);
27+
}
28+
29+
// Update all runs to clear the idempotency key
30+
await this._prisma.taskRun.updateMany({
31+
where: {
32+
idempotencyKey,
33+
taskIdentifier,
34+
runtimeEnvironmentId: authenticatedEnv.id,
35+
},
36+
data: {
37+
idempotencyKey: null,
38+
idempotencyKeyExpiresAt: null,
39+
},
40+
});
41+
42+
return { id: idempotencyKey };
43+
}
44+
}

docs/idempotency.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,29 @@ function hash(payload: any): string {
153153
}
154154
```
155155

156+
## Resetting idempotency keys
157+
158+
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.
159+
160+
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.
161+
162+
```ts
163+
import { idempotencyKeys } from "@trigger.dev/sdk";
164+
165+
// Reset an idempotency key for a specific task
166+
await idempotencyKeys.reset("my-task", "my-idempotency-key");
167+
```
168+
169+
The `reset` function requires both parameters:
170+
- `taskIdentifier`: The identifier of the task (e.g., `"my-task"`)
171+
- `idempotencyKey`: The idempotency key to reset
172+
173+
After resetting, any subsequent triggers with the same idempotency key will create new task runs instead of returning the existing ones.
174+
175+
<Note>
176+
Resetting an idempotency key only affects runs in the current environment. The reset is scoped to the specific task identifier and idempotency key combination.
177+
</Note>
178+
156179
## Important notes
157180

158181
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.

0 commit comments

Comments
 (0)