160 lines
6.8 KiB
TypeScript
160 lines
6.8 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// Stub `~/db.server` before importing the concern — the real module
|
|
// eagerly calls `prisma.$connect()` at singleton construction, which
|
|
// would fail without a database. The concern under test receives its
|
|
// prisma via the constructor, so these empty stubs are never used by the
|
|
// tested path; the run-ops singletons only satisfy the concern's static
|
|
// imports (vitest validates every named import against the mock).
|
|
vi.mock("~/db.server", () => ({
|
|
prisma: {},
|
|
$replica: {},
|
|
runOpsNewPrisma: {},
|
|
runOpsLegacyPrisma: {},
|
|
runOpsNewReplica: {},
|
|
runOpsLegacyReplica: {},
|
|
}));
|
|
|
|
// The IdempotencyKeyConcern resolves the pre-gate claim through the
|
|
// global mollifier buffer (`getMollifierBuffer`), shared by both
|
|
// `claimOrAwait` and `findBufferedRunWithIdempotency`. Control it via a
|
|
// hoisted handle so each test can script the claim/lookup responses.
|
|
const h = vi.hoisted(() => ({ buffer: null as unknown, orgFlag: true }));
|
|
vi.mock("~/v3/mollifier/mollifierBuffer.server", () => ({
|
|
getMollifierBuffer: () => h.buffer,
|
|
}));
|
|
// Stub `mollifierGate.server` so loading the concern doesn't drag in
|
|
// `env.server` (which fails to parse without a populated environment in
|
|
// CI). The concern only uses `makeResolveMollifierFlag` to gate the
|
|
// claim; tests flip `h.orgFlag` to cover both opted-in and opted-out
|
|
// orgs without touching real env or feature-flag wiring.
|
|
vi.mock("~/v3/mollifier/mollifierGate.server", () => ({
|
|
makeResolveMollifierFlag: () => async () => h.orgFlag,
|
|
}));
|
|
// Pin the idempotency dedup routing to the injected fake prisma: split OFF
|
|
// makes resolveIdempotencyDedupClient return the concern's constructor client,
|
|
// so these tests exercise claim resolution deterministically regardless of the
|
|
// ambient RUN_OPS_SPLIT_ENABLED (the split path routes to the empty runOps mocks).
|
|
vi.mock("~/v3/runOpsMigration/splitMode.server", () => ({
|
|
isSplitEnabled: async () => false,
|
|
}));
|
|
|
|
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
|
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
|
|
import type { TriggerTaskRequest } from "~/runEngine/types";
|
|
|
|
function makeConcern(prisma: { findFirst: () => Promise<unknown> }) {
|
|
return new IdempotencyKeyConcern(
|
|
{ taskRun: { findFirst: prisma.findFirst } } as never,
|
|
{} as never, // engine — unused on this path
|
|
{} as never // traceEventConcern — unused on this path
|
|
);
|
|
}
|
|
|
|
function makeRequest(): TriggerTaskRequest {
|
|
return {
|
|
taskId: "my-task",
|
|
environment: {
|
|
id: "env_a",
|
|
organizationId: "org_1",
|
|
// The pre-gate claim is gated by the per-org mollifier flag
|
|
// (mirroring evaluateGate's gating) so non-opted-in orgs don't pay
|
|
// the Redis SETNX. Tests covering the claim path must opt this
|
|
// fake org in, otherwise the concern skips claimOrAwait entirely
|
|
// and the resolution branches under test never run.
|
|
organization: { featureFlags: { mollifierEnabled: true } },
|
|
},
|
|
options: {},
|
|
body: { options: { idempotencyKey: "k-1" } },
|
|
} as unknown as TriggerTaskRequest;
|
|
}
|
|
|
|
describe("IdempotencyKeyConcern · claim resolution", () => {
|
|
it("resolved-but-unfindable falls through to a fresh trigger (no cached run, no claim held)", async () => {
|
|
// The claim slot holds a runId that is gone from both stores: the PG
|
|
// findFirst misses and the buffer lookup misses. Regression guard for
|
|
// the resolved-but-unfindable terminal case — the concern must fall
|
|
// through to a fresh trigger rather than throw, hand back a bogus
|
|
// cached run, or claim ownership it doesn't hold.
|
|
const lookupIdempotency = vi.fn(async () => null);
|
|
h.buffer = {
|
|
claimIdempotency: vi.fn(async () => ({ kind: "resolved", runId: "run_gone" })),
|
|
lookupIdempotency,
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const findFirst = vi.fn(async () => null); // PG misses on every call
|
|
const concern = makeConcern({ findFirst });
|
|
|
|
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
|
|
|
|
expect(result.isCached).toBe(false);
|
|
if (result.isCached === false) {
|
|
// No claim held — we resolved someone else's (stale) claim, we did
|
|
// not win one. The caller must NOT publish/release on our behalf.
|
|
expect(result.claim).toBeUndefined();
|
|
expect(result.idempotencyKey).toBe("k-1");
|
|
}
|
|
// We attempted the buffer fallback before giving up.
|
|
expect(lookupIdempotency).toHaveBeenCalled();
|
|
});
|
|
|
|
it("resolved-and-findable returns the existing run as a cached hit", async () => {
|
|
// Guard the happy resolved path: when the claimed runId IS findable
|
|
// (writer-side PG), the fall-through change must not swallow it.
|
|
h.buffer = {
|
|
claimIdempotency: vi.fn(async () => ({ kind: "resolved", runId: "run_winner" })),
|
|
lookupIdempotency: vi.fn(async () => null),
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const winner = { id: "run_winner", friendlyId: "run_winner" };
|
|
// First findFirst (initial existingRun check) misses so we enter the
|
|
// claim path; the second (writer-side re-resolve) finds the winner.
|
|
let calls = 0;
|
|
const findFirst = vi.fn(async () => {
|
|
calls += 1;
|
|
return calls >= 2 ? winner : null;
|
|
});
|
|
const concern = makeConcern({ findFirst });
|
|
|
|
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
|
|
|
|
expect(result.isCached).toBe(true);
|
|
if (result.isCached === true) {
|
|
expect(result.run).toBe(winner);
|
|
}
|
|
});
|
|
|
|
it("non-opted-in org skips claimOrAwait entirely (no buffer round-trip, no claim held)", async () => {
|
|
// Regression guard for the per-org gating that keeps the claim's
|
|
// Redis SETNX off the hot path for orgs that haven't opted into the
|
|
// mollifier — even when `TRIGGER_MOLLIFIER_ENABLED=1` globally and
|
|
// the buffer singleton exists. The concern should NOT touch
|
|
// `claimIdempotency` for these orgs; PG's unique constraint already
|
|
// deduplicates same-key races on the pass-through path.
|
|
h.orgFlag = false;
|
|
const claimIdempotency = vi.fn(async () => ({ kind: "claimed" as const }));
|
|
const lookupIdempotency = vi.fn(async () => null);
|
|
h.buffer = {
|
|
claimIdempotency,
|
|
lookupIdempotency,
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const findFirst = vi.fn(async () => null);
|
|
const concern = makeConcern({ findFirst });
|
|
|
|
try {
|
|
const result = await concern.handleTriggerRequest(makeRequest(), undefined);
|
|
expect(result.isCached).toBe(false);
|
|
if (result.isCached === false) {
|
|
// No claim returned — the caller must NOT publish/release.
|
|
expect(result.claim).toBeUndefined();
|
|
expect(result.idempotencyKey).toBe("k-1");
|
|
}
|
|
// The headline guarantee: zero Redis claim activity for this org.
|
|
expect(claimIdempotency).not.toHaveBeenCalled();
|
|
} finally {
|
|
h.orgFlag = true; // restore for any later tests in this file
|
|
}
|
|
});
|
|
});
|