Skip to content

Commit 36168b3

Browse files
ericallamsamejr
andauthored
feat(sdk): expose user-provided idempotency key and scope in task context (#2903)
## Summary - Store the original user-provided idempotency key and scope alongside the hash - Expose `ctx.run.idempotencyKey` as the user-provided key (not the hash) - Add `ctx.run.idempotencyKeyScope` to show the scope ("run", "attempt", or "global") <img width="539" height="450" alt="CleanShot 2026-01-19 at 11 40 46" src="https://github.com/user-attachments/assets/b6f42991-697e-4314-a164-aef77b8fd25c" /> ## Problem Idempotency keys were hashed (SHA-256) before storage, making debugging difficult since users couldn't see the value they originally set or search for runs by idempotency key. ## Solution Attach metadata to the `String` object returned by `idempotencyKeys.create()` using a Symbol, extract it in the SDK before the API call, and store it in the database alongside the hash. ```typescript const key = await idempotencyKeys.create("my-key", { scope: "global" }); await childTask.triggerAndWait(payload, { idempotencyKey: key }); // In child task: ctx.run.idempotencyKey // "my-key" (previously showed the hash) ctx.run.idempotencyKeyScope // "global" ``` Test plan - Trigger task with idempotencyKeys.create() using different scopes (run, attempt, global) - Verify ctx.run.idempotencyKey returns user-provided key - Verify ctx.run.idempotencyKeyScope returns correct scope - Verify PostgreSQL stores idempotencyKeyOptions JSON - Verify ClickHouse receives idempotency_key_user and idempotency_key_scope via replication --------- Co-authored-by: James Ritchie <james@trigger.dev>
1 parent c859be9 commit 36168b3

File tree

29 files changed

+1104
-163
lines changed

29 files changed

+1104
-163
lines changed

.changeset/bright-keys-shine.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
"@trigger.dev/core": patch
4+
---
5+
6+
Expose user-provided idempotency key and scope in task context. `ctx.run.idempotencyKey` now returns the original key passed to `idempotencyKeys.create()` instead of the hash, and `ctx.run.idempotencyKeyScope` shows the scope ("run", "attempt", or "global").

apps/webapp/app/presenters/v3/ApiRetrieveRunPresenter.server.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
logger,
1010
} from "@trigger.dev/core/v3";
1111
import { parsePacketAsJson } from "@trigger.dev/core/v3/utils/ioSerialization";
12+
import { getUserProvidedIdempotencyKey } from "@trigger.dev/core/v3/serverOnly";
1213
import { Prisma, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
1314
import assertNever from "assert-never";
1415
import { API_VERSIONS, CURRENT_API_VERSION, RunStatusUnspecifiedApiVersion } from "~/api/versions";
@@ -38,6 +39,7 @@ const commonRunSelect = {
3839
baseCostInCents: true,
3940
usageDurationMs: true,
4041
idempotencyKey: true,
42+
idempotencyKeyOptions: true,
4143
isTest: true,
4244
depth: true,
4345
scheduleId: true,
@@ -442,7 +444,7 @@ async function createCommonRunStructure(run: CommonRelatedRun, apiVersion: API_V
442444
return {
443445
id: run.friendlyId,
444446
taskIdentifier: run.taskIdentifier,
445-
idempotencyKey: run.idempotencyKey ?? undefined,
447+
idempotencyKey: getUserProvidedIdempotencyKey(run),
446448
version: run.lockedToVersion?.version,
447449
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(run.status, apiVersion),
448450
createdAt: run.createdAt,

apps/webapp/app/presenters/v3/SpanPresenter.server.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
type V3TaskRunContext,
99
} from "@trigger.dev/core/v3";
1010
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
11+
import {
12+
extractIdempotencyKeyScope,
13+
getUserProvidedIdempotencyKey,
14+
} from "@trigger.dev/core/v3/serverOnly";
1115
import { RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
1216
import { logger } from "~/services/logger.server";
1317
import { rehydrateAttribute } from "~/v3/eventRepository/eventRepository.server";
@@ -229,8 +233,10 @@ export class SpanPresenter extends BasePresenter {
229233
isTest: run.isTest,
230234
replayedFromTaskRunFriendlyId: run.replayedFromTaskRunFriendlyId,
231235
environmentId: run.runtimeEnvironment.id,
232-
idempotencyKey: run.idempotencyKey,
236+
idempotencyKey: getUserProvidedIdempotencyKey(run),
233237
idempotencyKeyExpiresAt: run.idempotencyKeyExpiresAt,
238+
idempotencyKeyScope: extractIdempotencyKeyScope(run),
239+
idempotencyKeyStatus: this.getIdempotencyKeyStatus(run),
234240
debounce: run.debounce as { key: string; delay: string; createdAt: Date } | null,
235241
schedule: await this.resolveSchedule(run.scheduleId ?? undefined),
236242
queue: {
@@ -276,6 +282,30 @@ export class SpanPresenter extends BasePresenter {
276282
};
277283
}
278284

285+
private getIdempotencyKeyStatus(run: {
286+
idempotencyKey: string | null;
287+
idempotencyKeyExpiresAt: Date | null;
288+
idempotencyKeyOptions: unknown;
289+
}): "active" | "inactive" | "expired" | undefined {
290+
// No idempotency configured if no scope exists
291+
const scope = extractIdempotencyKeyScope(run);
292+
if (!scope) {
293+
return undefined;
294+
}
295+
296+
// Check if expired first (takes precedence)
297+
if (run.idempotencyKeyExpiresAt && run.idempotencyKeyExpiresAt < new Date()) {
298+
return "expired";
299+
}
300+
301+
// Check if reset (hash is null but options exist)
302+
if (run.idempotencyKey === null) {
303+
return "inactive";
304+
}
305+
306+
return "active";
307+
}
308+
279309
async resolveSchedule(scheduleId?: string) {
280310
if (!scheduleId) {
281311
return;
@@ -355,6 +385,7 @@ export class SpanPresenter extends BasePresenter {
355385
//idempotency
356386
idempotencyKey: true,
357387
idempotencyKeyExpiresAt: true,
388+
idempotencyKeyOptions: true,
358389
//debounce
359390
debounce: true,
360391
//delayed
@@ -644,7 +675,7 @@ export class SpanPresenter extends BasePresenter {
644675
createdAt: run.createdAt,
645676
tags: run.runTags,
646677
isTest: run.isTest,
647-
idempotencyKey: run.idempotencyKey ?? undefined,
678+
idempotencyKey: getUserProvidedIdempotencyKey(run) ?? undefined,
648679
startedAt: run.startedAt ?? run.createdAt,
649680
durationMs: run.usageDurationMs,
650681
costInCents: run.costInCents,

apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.idempotencyKey.reset.tsx

Lines changed: 9 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,16 @@
1-
import { parse } from "@conform-to/zod";
21
import { type ActionFunction, json } from "@remix-run/node";
3-
import { z } from "zod";
42
import { prisma } from "~/db.server";
5-
import { jsonWithErrorMessage } from "~/models/message.server";
3+
import { jsonWithErrorMessage, jsonWithSuccessMessage } from "~/models/message.server";
64
import { logger } from "~/services/logger.server";
75
import { requireUserId } from "~/services/session.server";
86
import { ResetIdempotencyKeyService } from "~/v3/services/resetIdempotencyKey.server";
97
import { v3RunParamsSchema } from "~/utils/pathBuilder";
108

11-
export const resetIdempotencyKeySchema = z.object({
12-
taskIdentifier: z.string().min(1, "Task identifier is required"),
13-
});
14-
159
export const action: ActionFunction = async ({ request, params }) => {
1610
const userId = await requireUserId(request);
17-
const { projectParam, organizationSlug, envParam, runParam } =
18-
v3RunParamsSchema.parse(params);
19-
20-
const formData = await request.formData();
21-
const submission = parse(formData, { schema: resetIdempotencyKeySchema });
22-
23-
if (!submission.value) {
24-
return json(submission);
25-
}
11+
const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params);
2612

2713
try {
28-
const { taskIdentifier } = submission.value;
29-
3014
const taskRun = await prisma.taskRun.findFirst({
3115
where: {
3216
friendlyId: runParam,
@@ -54,21 +38,11 @@ export const action: ActionFunction = async ({ request, params }) => {
5438
});
5539

5640
if (!taskRun) {
57-
submission.error = { runParam: ["Run not found"] };
58-
return json(submission);
41+
return jsonWithErrorMessage({}, request, "Run not found");
5942
}
6043

6144
if (!taskRun.idempotencyKey) {
62-
return jsonWithErrorMessage(
63-
submission,
64-
request,
65-
"This run does not have an idempotency key"
66-
);
67-
}
68-
69-
if (taskRun.taskIdentifier !== taskIdentifier) {
70-
submission.error = { taskIdentifier: ["Task identifier does not match this run"] };
71-
return json(submission);
45+
return jsonWithErrorMessage({}, request, "This run does not have an idempotency key");
7246
}
7347

7448
const environment = await prisma.runtimeEnvironment.findUnique({
@@ -85,22 +59,18 @@ export const action: ActionFunction = async ({ request, params }) => {
8559
});
8660

8761
if (!environment) {
88-
return jsonWithErrorMessage(
89-
submission,
90-
request,
91-
"Environment not found"
92-
);
62+
return jsonWithErrorMessage({}, request, "Environment not found");
9363
}
9464

9565
const service = new ResetIdempotencyKeyService();
9666

97-
await service.call(taskRun.idempotencyKey, taskIdentifier, {
67+
await service.call(taskRun.idempotencyKey, taskRun.taskIdentifier, {
9868
...environment,
9969
organizationId: environment.project.organizationId,
10070
organization: environment.project.organization,
10171
});
10272

103-
return json({ success: true });
73+
return jsonWithSuccessMessage({}, request, "Idempotency key reset successfully");
10474
} catch (error) {
10575
if (error instanceof Error) {
10676
logger.error("Failed to reset idempotency key", {
@@ -110,15 +80,11 @@ export const action: ActionFunction = async ({ request, params }) => {
11080
stack: error.stack,
11181
},
11282
});
113-
return jsonWithErrorMessage(
114-
submission,
115-
request,
116-
`Failed to reset idempotency key: ${error.message}`
117-
);
83+
return jsonWithErrorMessage({}, request, `Failed to reset idempotency key: ${error.message}`);
11884
} else {
11985
logger.error("Failed to reset idempotency key", { error });
12086
return jsonWithErrorMessage(
121-
submission,
87+
{},
12288
request,
12389
`Failed to reset idempotency key: ${JSON.stringify(error)}`
12490
);

0 commit comments

Comments
 (0)