From 3167cdca6d65428713c82293f95e5486481fd65b Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 1 Jul 2026 15:35:24 +0100 Subject: [PATCH 1/2] fix(webapp): dedupe bulk replay triggers --- .../app/v3/services/replayTaskRun.server.ts | 5 +- .../test/replayTaskRunIdempotency.test.ts | 74 +++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 apps/webapp/test/replayTaskRunIdempotency.test.ts diff --git a/apps/webapp/app/v3/services/replayTaskRun.server.ts b/apps/webapp/app/v3/services/replayTaskRun.server.ts index ed2292e32b2..94727de46d6 100644 --- a/apps/webapp/app/v3/services/replayTaskRun.server.ts +++ b/apps/webapp/app/v3/services/replayTaskRun.server.ts @@ -61,6 +61,9 @@ export class ReplayTaskRunService extends BaseService { const payloadType = payloadPacket.dataType; const metadata = overrideOptions.metadata ?? (await this.getExistingMetadata(existingTaskRun)); const tags = overrideOptions.tags ?? existingTaskRun.runTags; + const idempotencyKey = overrideOptions.bulkActionId + ? `bulk-replay:${overrideOptions.bulkActionId}:${existingTaskRun.id}` + : overrideOptions.idempotencyKey; // Only use the region from the existing run if V2 engine and neither environment is dev const ignoreRegion = existingTaskRun.engine === "V1" || @@ -100,7 +103,7 @@ export class ReplayTaskRunService extends BaseService { ? new Date(Date.now() + overrideOptions.delaySeconds * 1000) : undefined, ttl: overrideOptions.ttlSeconds, - idempotencyKey: overrideOptions.idempotencyKey, + idempotencyKey, idempotencyKeyTTL: overrideOptions.idempotencyKeyTTLSeconds ? `${overrideOptions.idempotencyKeyTTLSeconds}s` : undefined, diff --git a/apps/webapp/test/replayTaskRunIdempotency.test.ts b/apps/webapp/test/replayTaskRunIdempotency.test.ts new file mode 100644 index 00000000000..a04fde3cafd --- /dev/null +++ b/apps/webapp/test/replayTaskRunIdempotency.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("~/db.server", () => ({ prisma: {}, $replica: {} })); + +vi.mock("~/models/runtimeEnvironment.server", () => ({ + findEnvironmentById: vi.fn(async () => ({ + id: "env_1", + type: "PRODUCTION", + archivedAt: null, + })), +})); + +const triggerCall = vi.fn(async () => ({ + run: { id: "run_new", friendlyId: "run_new_friendly" }, + isCached: false, +})); + +vi.mock("~/v3/services/triggerTask.server", () => ({ + TriggerTaskService: class { + call = triggerCall; + }, + OutOfEntitlementError: class OutOfEntitlementError extends Error {}, +})); + +import { ReplayTaskRunService } from "~/v3/services/replayTaskRun.server"; + +const SOURCE_RUN = { + id: "run_source_internal", + friendlyId: "run_source_friendly", + taskIdentifier: "hello-world", + runtimeEnvironmentId: "env_1", + payload: JSON.stringify({ message: "hi" }), + payloadType: "application/json", + seedMetadata: null, + seedMetadataType: "application/json", + runTags: [], + queue: "task/hello-world", + workerQueue: "worker-queue-1", + concurrencyKey: null, + machinePreset: "small-1x", + isTest: false, + engine: "V2", + region: null, + traceId: "trace_1", + spanId: "span_1", + realtimeStreamsVersion: "v1", +} as any; + +function makeFakePrisma() { + return { + runtimeEnvironment: { + findFirstOrThrow: vi.fn(async () => ({ id: "env_1", type: "PRODUCTION" })), + }, + taskQueue: { + findFirst: vi.fn(async () => null), + }, + } as any; +} + +describe("ReplayTaskRunService idempotency (TRI-10467 fix #3)", () => { + beforeEach(() => { + triggerCall.mockClear(); + }); + + it("sets a stable idempotency key for bulk replay source runs", async () => { + const service = new ReplayTaskRunService(makeFakePrisma()); + + await service.call(SOURCE_RUN, { bulkActionId: "bulk_1", triggerSource: "dashboard" }); + + expect(triggerCall.mock.calls[0][2].options.idempotencyKey).toBe( + "bulk-replay:bulk_1:run_source_internal" + ); + }); +}); From ac46b3f88400b7a1651fcdb157884f06ad5c8f7f Mon Sep 17 00:00:00 2001 From: Chris Arderne Date: Wed, 1 Jul 2026 15:47:34 +0100 Subject: [PATCH 2/2] add .server-change --- .server-changes/bulk-replay-idempotency.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .server-changes/bulk-replay-idempotency.md diff --git a/.server-changes/bulk-replay-idempotency.md b/.server-changes/bulk-replay-idempotency.md new file mode 100644 index 00000000000..17b3a8b840e --- /dev/null +++ b/.server-changes/bulk-replay-idempotency.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Prevent duplicate bulk replay attempts from creating multiple replayed runs for the same source run