Files
triggerdotdev--trigger.dev/apps/webapp/test/mollifierResolveRunForMutation.test.ts
Daniel Sutton e1950778e2 feat(webapp): mollifier API mutations on buffered runs (#3756)
## Summary

Cancel, replay, reschedule, metadata, tags, and idempotency-key-reset
now succeed against a run that's still in the mollifier buffer.
Mutations are applied to the buffered snapshot via Lua CAS; the drainer
carries the mutation forward when it replays.

Primitives added:

- `mutateWithFallback` — PG-first / buffer-fallback resolver with
bounded-wait safety net for entries that transition mid-mutation.
- `applyMetadataMutation` — buffered metadata PUT mirroring the PG-side
retry loop with CAS atomicity.
- `resolveRunForMutation` — discriminated-union resolver used by route
`findResource` so the route builder's pre-action 404 check sees buffered
runs.

Routes wired (whole files, no GET/POST splits):
- `api.v2.runs.\$runParam.cancel.ts`
- `api.v1.runs.\$runParam.replay.ts`
- `api.v1.runs.\$runParam.reschedule.ts`
- `api.v1.runs.\$runId.metadata.ts`
- `api.v1.runs.\$runId.tags.ts`
- `resetIdempotencyKey.server.ts`

Stacked on the reads PR.

## Test plan

- [x] \`pnpm run typecheck --filter webapp\` passes
- [x] \`pnpm run test --filter webapp
test/mollifierMutateWithFallback.test.ts\` passes
- [x] \`pnpm run test --filter webapp
test/mollifierApplyMetadataMutation.test.ts\` passes
- [x] \`pnpm run test --filter webapp
test/mollifierResolveRunForMutation.test.ts\` passes

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 16:24:10 +01:00

230 lines
7.4 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
vi.mock("~/db.server", () => ({
// Both default clients return null. Individual tests inject their
// own fakes via `deps` when they want non-default behaviour.
prisma: { taskRun: { findFirst: vi.fn(async () => null) } },
$replica: { taskRun: { findFirst: vi.fn(async () => null) } },
}));
import { resolveRunForMutation } from "~/v3/mollifier/resolveRunForMutation.server";
import type { BufferEntry, MollifierBuffer } from "@trigger.dev/redis-worker";
// Regression coverage for the cancel-route 404 bug (commit b490afe23).
// Before the fix the route had `findResource: async () => null`, which
// caused the route builder to 404 every cancel — including for valid
// PG-row runs — BEFORE the action handler could run. The helper
// resolveRunForMutation has to return a non-null discriminated value
// whenever the run exists in either store.
const NOW = new Date("2026-05-21T10:00:00Z");
function fakeReplica(row: { friendlyId: string } | null) {
return { taskRun: { findFirst: vi.fn(async () => row) } };
}
function fakeWriter(row: { friendlyId: string } | null) {
return { taskRun: { findFirst: vi.fn(async () => row) } };
}
function fakeBuffer(entry: BufferEntry | null): MollifierBuffer {
return {
getEntry: vi.fn(async () => entry),
} as unknown as MollifierBuffer;
}
const baseInput = {
runParam: "run_1",
environmentId: "env_a",
organizationId: "org_1",
};
describe("resolveRunForMutation", () => {
it("returns { source: 'pg' } when the PG row exists", async () => {
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica({ friendlyId: "run_1" }),
getBuffer: () => null,
},
});
expect(result).toEqual({ source: "pg", friendlyId: "run_1" });
});
it("returns { source: 'buffer' } when PG misses and the buffer entry matches env+org", async () => {
const entry: BufferEntry = {
runId: "run_1",
envId: "env_a",
orgId: "org_1",
payload: "{}",
status: "QUEUED",
attempts: 0,
createdAt: NOW,
createdAtMicros: 1747044000000000,
materialised: false,
idempotencyLookupKey: "",
metadataVersion: 0,
};
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
getBuffer: () => fakeBuffer(entry),
},
});
expect(result).toEqual({ source: "buffer", friendlyId: "run_1" });
});
it("returns null when PG misses and the buffer entry env doesn't match", async () => {
const entry: BufferEntry = {
runId: "run_1",
envId: "env_OTHER",
orgId: "org_1",
payload: "{}",
status: "QUEUED",
attempts: 0,
createdAt: NOW,
createdAtMicros: 1747044000000000,
materialised: false,
idempotencyLookupKey: "",
metadataVersion: 0,
};
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
getBuffer: () => fakeBuffer(entry),
},
});
expect(result).toBeNull();
});
it("returns null when PG misses and the buffer entry org doesn't match", async () => {
const entry: BufferEntry = {
runId: "run_1",
envId: "env_a",
orgId: "org_OTHER",
payload: "{}",
status: "QUEUED",
attempts: 0,
createdAt: NOW,
createdAtMicros: 1747044000000000,
materialised: false,
idempotencyLookupKey: "",
metadataVersion: 0,
};
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
getBuffer: () => fakeBuffer(entry),
},
});
expect(result).toBeNull();
});
it("returns null when both PG and buffer miss", async () => {
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
getBuffer: () => fakeBuffer(null),
},
});
expect(result).toBeNull();
});
it("returns null when buffer is unavailable (mollifier disabled) and PG misses", async () => {
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
getBuffer: () => null,
},
});
expect(result).toBeNull();
});
it("PG-hit short-circuits before consulting the buffer", async () => {
const buffer = fakeBuffer(null);
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica({ friendlyId: "run_1" }),
getBuffer: () => buffer,
},
});
expect(result?.source).toBe("pg");
expect(buffer.getEntry).not.toHaveBeenCalled();
});
// Regressions for the degraded-mode false-404 CodeRabbit flagged.
//
// Pre-PR the mutation routes read from the writer directly, so any
// PG row was visible regardless of replication lag. This helper
// moved the read to the replica for offload purposes. The route
// builder treats a null return as a hard 404 BEFORE the action
// handler runs, so any path where replica misses and the writer has
// the row needs to be reachable here — otherwise mutateWithFallback's
// own writer recovery never gets a chance to fire.
it("falls back to the writer when both replica and buffer miss, returning the writer row as 'pg' source", async () => {
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
prismaWriter: fakeWriter({ friendlyId: "run_1" }),
getBuffer: () => fakeBuffer(null),
},
});
expect(result?.source).toBe("pg");
expect(result?.friendlyId).toBe("run_1");
});
it("falls back to the writer when the buffer is unavailable (mollifier disabled) and replica misses", async () => {
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
prismaWriter: fakeWriter({ friendlyId: "run_1" }),
getBuffer: () => null,
},
});
expect(result?.source).toBe("pg");
expect(result?.friendlyId).toBe("run_1");
});
it("still returns null when replica, buffer, AND writer all miss (legitimate not-found)", async () => {
const writer = fakeWriter(null);
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica(null),
prismaWriter: writer,
getBuffer: () => fakeBuffer(null),
},
});
expect(result).toBeNull();
// Writer probe ran — the fallback fires exactly once on the miss
// path; doesn't pile retries.
expect(writer.taskRun.findFirst).toHaveBeenCalledOnce();
});
it("PG-hit short-circuits before consulting either the buffer OR the writer", async () => {
const buffer = fakeBuffer(null);
const writer = fakeWriter({ friendlyId: "should-not-be-read" });
const result = await resolveRunForMutation({
...baseInput,
deps: {
prismaReplica: fakeReplica({ friendlyId: "run_1" }),
prismaWriter: writer,
getBuffer: () => buffer,
},
});
expect(result?.source).toBe("pg");
expect(result?.friendlyId).toBe("run_1");
expect(buffer.getEntry).not.toHaveBeenCalled();
// Writer must NOT fire when the replica already had the row —
// otherwise we'd negate the whole replica-offload purpose.
expect(writer.taskRun.findFirst).not.toHaveBeenCalled();
});
});