import { heteroPostgresTest } from "@internal/testcontainers"; import type { PrismaClient } from "@trigger.dev/database"; import { generateRunOpsId } from "@trigger.dev/core/v3/isomorphic"; import { describe, expect, vi } from "vitest"; // Stub `~/db.server` so the `runStore` singleton doesn't eagerly connect at // import. The concern passes its constructor `prisma` arg as the explicit // client/tx to every store call, so the singleton's bound handles are never // exercised — the passed client runs the query. Mirrors the shipped // `mollifierClaimResolution` test: env-wiring mock only; the DB under test is // the real PG14 + PG17 hetero-fixture containers. vi.mock("~/db.server", () => ({ prisma: {}, $replica: {}, runOpsNewPrisma: {}, runOpsLegacyPrisma: {}, })); // Keep split off so resolveIdempotencyDedupClient returns this.prisma (the hetero fixture client). vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({ isSplitEnabled: async () => false })); import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server"; import type { TriggerTaskRequest } from "~/runEngine/types"; vi.setConfig({ testTimeout: 60_000 }); // The constructor `prisma` arg is the client the four store sites execute // against. With the run-ops split off (mocked above) the dedup resolver is a // pass-through that returns this same client, so constructing with the PG14 or // PG17 fixture client decides which DB the residency-routed dedup probe reads. function makeConcern(client: PrismaClient) { return new IdempotencyKeyConcern( client as never, {} as never, // engine — unused on the reuse / clear paths {} as never // traceEventConcern — unused on the reuse / clear paths ); } function makeRequest(opts: { environmentId: string; organizationId: string; projectId: string; taskId: string; idempotencyKey: string; }): TriggerTaskRequest { return { taskId: opts.taskId, environment: { id: opts.environmentId, organizationId: opts.organizationId, projectId: opts.projectId, // Leave the org mollifier flag unset so the pre-gate claim path is // skipped — this test exercises the PG existing-run lookup + clear, // not the Redis claim. (resolveOrgMollifierFlag returns falsy for an // org with no mollifier flag, so claimEligible is false.) organization: { featureFlags: {} }, }, options: {}, body: { options: { idempotencyKey: opts.idempotencyKey } }, } as unknown as TriggerTaskRequest; } async function seedOrgProjectEnv(prisma: PrismaClient, suffix: string) { const organization = await prisma.organization.create({ data: { title: `test-${suffix}`, slug: `test-${suffix}` }, }); const project = await prisma.project.create({ data: { name: `test-${suffix}`, slug: `test-${suffix}`, organizationId: organization.id, externalRef: `test-${suffix}`, }, }); const runtimeEnvironment = await prisma.runtimeEnvironment.create({ data: { slug: `test-${suffix}`, type: "DEVELOPMENT", projectId: project.id, organizationId: organization.id, apiKey: `test-${suffix}`, pkApiKey: `test-${suffix}`, shortcode: `test-${suffix}`, }, }); return { organization, project, runtimeEnvironment }; } async function seedRun( prisma: PrismaClient, args: { runtimeEnvironmentId: string; projectId: string; organizationId: string; taskIdentifier: string; idempotencyKey: string; status?: "PENDING" | "EXECUTING" | "COMPLETED_SUCCESSFULLY" | "COMPLETED_WITH_ERRORS"; idempotencyKeyExpiresAt?: Date; } ) { const runId = generateRunOpsId(); return prisma.taskRun.create({ data: { id: runId, friendlyId: `run_${runId}`, taskIdentifier: args.taskIdentifier, idempotencyKey: args.idempotencyKey, idempotencyKeyExpiresAt: args.idempotencyKeyExpiresAt ?? null, status: args.status ?? "EXECUTING", payload: JSON.stringify({ foo: "bar" }), payloadType: "application/json", traceId: "1234", spanId: "1234", queue: "test", runtimeEnvironmentId: args.runtimeEnvironmentId, projectId: args.projectId, organizationId: args.organizationId, environmentType: "DEVELOPMENT", engine: "V2", }, }); } describe("IdempotencyKeyConcern · residency-routed dedup (cross-DB)", () => { heteroPostgresTest( "resolves a legacy-resident key against the legacy DB; a key whose run lives on the new DB is resolved against the new DB", async ({ prisma14, prisma17 }) => { const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv( prisma14, "legacy-auth" ); // Seed the same org/project/env shape on the NEW (PG17) DB so we can // place a row there for a *different* key — proving the legacy-pinned // read does not see it. const newSide = await seedOrgProjectEnv(prisma17, "new-side"); const reusedKey = "idem-reuse-1"; // The authoritative existing run lives on the LEGACY (PG14) DB. const legacyRun = await seedRun(prisma14, { runtimeEnvironmentId: runtimeEnvironment.id, projectId: project.id, organizationId: organization.id, taskIdentifier: "my-task", idempotencyKey: reusedKey, status: "EXECUTING", }); // A row for a DIFFERENT key lives only on the NEW (PG17) DB. const newOnlyKey = "idem-new-only"; await seedRun(prisma17, { runtimeEnvironmentId: newSide.runtimeEnvironment.id, projectId: newSide.project.id, organizationId: newSide.organization.id, taskIdentifier: "my-task", idempotencyKey: newOnlyKey, status: "EXECUTING", }); const concern = makeConcern(prisma14); // (1) Reuse with the legacy key resolves the legacy-seeded run. const reuse = await concern.handleTriggerRequest( makeRequest({ environmentId: runtimeEnvironment.id, organizationId: organization.id, projectId: project.id, taskId: "my-task", idempotencyKey: reusedKey, }), undefined ); expect(reuse.isCached).toBe(true); if (reuse.isCached === true) { expect(reuse.run.id).toBe(legacyRun.id); } // Exactly one run matches the key on the legacy DB — no duplicate. const legacyMatches = await prisma14.taskRun.count({ where: { runtimeEnvironmentId: runtimeEnvironment.id, taskIdentifier: "my-task", idempotencyKey: reusedKey, }, }); expect(legacyMatches).toBe(1); // (2) A key whose run lives on the new DB is resolved against the new DB. const concernOnNew = makeConcern(prisma17); const newSideHit = await concernOnNew.handleTriggerRequest( makeRequest({ environmentId: newSide.runtimeEnvironment.id, organizationId: newSide.organization.id, projectId: newSide.project.id, taskId: "my-task", idempotencyKey: newOnlyKey, }), undefined ); expect(newSideHit.isCached).toBe(true); if (newSideHit.isCached === true) { expect(newSideHit.run.idempotencyKey).toBe(newOnlyKey); } // (3) An unknown key on the legacy env does not wrongly return the // stale legacy hit for a different key. const unknown = await concern.handleTriggerRequest( makeRequest({ environmentId: runtimeEnvironment.id, organizationId: organization.id, projectId: project.id, taskId: "my-task", idempotencyKey: "idem-never-seen", }), undefined ); expect(unknown.isCached).toBe(false); } ); heteroPostgresTest( "cleared-status reuse clears the key on the legacy (PG14) DB and proceeds with a fresh trigger", async ({ prisma14 }) => { const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv( prisma14, "cleared-status" ); const key = "idem-cleared-1"; // Existing run is in a failed (cleared) status — the concern must // clear its key against the legacy authority and return isCached:false. const legacyRun = await seedRun(prisma14, { runtimeEnvironmentId: runtimeEnvironment.id, projectId: project.id, organizationId: organization.id, taskIdentifier: "my-task", idempotencyKey: key, status: "COMPLETED_WITH_ERRORS", }); const concern = makeConcern(prisma14); const result = await concern.handleTriggerRequest( makeRequest({ environmentId: runtimeEnvironment.id, organizationId: organization.id, projectId: project.id, taskId: "my-task", idempotencyKey: key, }), undefined ); // A fresh trigger proceeds (not cached). expect(result.isCached).toBe(false); // The clear executed against the legacy (PG14) DB: re-query PG14 and // assert the key + its expiry are now null on the seeded run. const cleared = await prisma14.taskRun.findFirst({ where: { id: legacyRun.id } }); expect(cleared?.idempotencyKey).toBeNull(); expect(cleared?.idempotencyKeyExpiresAt).toBeNull(); } ); heteroPostgresTest( "expired idempotency key is cleared on the legacy (PG14) DB and a fresh trigger proceeds", async ({ prisma14 }) => { const { project, organization, runtimeEnvironment } = await seedOrgProjectEnv( prisma14, "expired-key" ); const key = "idem-expired-1"; const legacyRun = await seedRun(prisma14, { runtimeEnvironmentId: runtimeEnvironment.id, projectId: project.id, organizationId: organization.id, taskIdentifier: "my-task", idempotencyKey: key, status: "EXECUTING", idempotencyKeyExpiresAt: new Date(Date.now() - 60_000), // already expired }); const concern = makeConcern(prisma14); const result = await concern.handleTriggerRequest( makeRequest({ environmentId: runtimeEnvironment.id, organizationId: organization.id, projectId: project.id, taskId: "my-task", idempotencyKey: key, }), undefined ); expect(result.isCached).toBe(false); const cleared = await prisma14.taskRun.findFirst({ where: { id: legacyRun.id } }); expect(cleared?.idempotencyKey).toBeNull(); expect(cleared?.idempotencyKeyExpiresAt).toBeNull(); } ); });