479 lines
19 KiB
TypeScript
479 lines
19 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("~/db.server", () => ({
|
|
prisma: { taskRun: { findFirst: vi.fn(async () => null) } },
|
|
$replica: { taskRun: { findFirst: vi.fn(async () => null) } },
|
|
}));
|
|
|
|
import { mutateWithFallback } from "~/v3/mollifier/mutateWithFallback.server";
|
|
import type * as DbServer from "~/db.server";
|
|
import type { BufferEntry, MollifierBuffer, MutateSnapshotResult } from "@trigger.dev/redis-worker";
|
|
import type { TaskRun } from "@trigger.dev/database";
|
|
|
|
type FindFirst = ReturnType<typeof vi.fn>;
|
|
type PrismaStub = { taskRun: { findFirst: FindFirst } };
|
|
|
|
function fakePrisma(rows: Array<TaskRun | null>): PrismaStub {
|
|
const fn = vi.fn();
|
|
for (const r of rows) fn.mockResolvedValueOnce(r);
|
|
fn.mockResolvedValue(null);
|
|
return { taskRun: { findFirst: fn } };
|
|
}
|
|
|
|
// Env-matching entry returned by the env-pre-check getEntry call that
|
|
// mutateWithFallback now does before any buffer write (cross-env auth
|
|
// gate). Same envId/orgId as `baseInput` so the check passes and the
|
|
// flow under test proceeds to mutateSnapshot.
|
|
const preCheckEntry = (): BufferEntry =>
|
|
({
|
|
envId: "env_a",
|
|
orgId: "org_1",
|
|
status: "QUEUED",
|
|
materialised: false,
|
|
}) as unknown as BufferEntry;
|
|
|
|
function bufferReturning(result: MutateSnapshotResult): MollifierBuffer {
|
|
const getEntry = vi.fn(async () => preCheckEntry());
|
|
return {
|
|
mutateSnapshot: vi.fn(async () => result),
|
|
getEntry,
|
|
} as unknown as MollifierBuffer;
|
|
}
|
|
|
|
// Buffer whose mutateSnapshot returns "busy" and whose getEntry walks a
|
|
// scripted sequence of entry states. The pre-check getEntry call (one
|
|
// extra read before the busy-wait loop, used for env authorization)
|
|
// consumes the first scripted result, then the busy-wait loop pops the
|
|
// remainder; the last element repeats once the sequence is exhausted.
|
|
function bufferBusy(entries: Array<BufferEntry | null>): MollifierBuffer {
|
|
const getEntry = vi.fn();
|
|
// Pre-check consumes one entry. Use a QUEUED env-matching entry so
|
|
// the env-check passes and the flow reaches mutateSnapshot (which
|
|
// returns "busy") and enters the wait-loop.
|
|
getEntry.mockResolvedValueOnce(preCheckEntry());
|
|
for (const e of entries) getEntry.mockResolvedValueOnce(e);
|
|
getEntry.mockResolvedValue(entries.length ? entries[entries.length - 1] : null);
|
|
return {
|
|
mutateSnapshot: vi.fn(async () => "busy" as const),
|
|
getEntry,
|
|
} as unknown as MollifierBuffer;
|
|
}
|
|
|
|
const entryDraining = (): BufferEntry =>
|
|
({
|
|
envId: "env_a",
|
|
orgId: "org_1",
|
|
status: "DRAINING",
|
|
materialised: false,
|
|
}) as unknown as BufferEntry;
|
|
const entryQueued = (): BufferEntry =>
|
|
({
|
|
envId: "env_a",
|
|
orgId: "org_1",
|
|
status: "QUEUED",
|
|
materialised: false,
|
|
}) as unknown as BufferEntry;
|
|
const entryMaterialised = (): BufferEntry =>
|
|
({
|
|
envId: "env_a",
|
|
orgId: "org_1",
|
|
status: "DRAINING",
|
|
materialised: true,
|
|
}) as unknown as BufferEntry;
|
|
|
|
const fakeRun = (overrides: Partial<TaskRun> = {}): TaskRun =>
|
|
({
|
|
id: "pg_id",
|
|
friendlyId: "run_1",
|
|
runtimeEnvironmentId: "env_a",
|
|
...overrides,
|
|
}) as TaskRun;
|
|
|
|
const baseInput = {
|
|
runId: "run_1",
|
|
environmentId: "env_a",
|
|
organizationId: "org_1",
|
|
bufferPatch: { type: "append_tags" as const, tags: ["x"] },
|
|
};
|
|
|
|
describe("mutateWithFallback", () => {
|
|
it("hits replica → calls pgMutation, returns pg outcome", async () => {
|
|
const row = fakeRun();
|
|
const pgMutation = vi.fn(async () => "pg-response");
|
|
const synthesisedResponse = vi.fn(() => "snapshot-response");
|
|
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse,
|
|
prismaReplica: fakePrisma([row]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("applied_to_snapshot"),
|
|
});
|
|
|
|
expect(result).toEqual({ kind: "pg", response: "pg-response" });
|
|
expect(pgMutation).toHaveBeenCalledWith(row);
|
|
expect(synthesisedResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("replica miss + buffer applied_to_snapshot → synthesisedResponse", async () => {
|
|
const pgMutation = vi.fn(async () => "pg");
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("applied_to_snapshot"),
|
|
});
|
|
expect(result).toEqual({ kind: "snapshot", response: "snap" });
|
|
expect(pgMutation).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applied_to_snapshot forwards the pre-mutation entry to synthesisedResponse (lets callers dedup)", async () => {
|
|
// The tags route uses this to compute the same post-dedup count
|
|
// the PG path reports, without an extra Redis round-trip.
|
|
const synthesised = vi.fn(({ bufferEntry }: { bufferEntry: BufferEntry | null }) => {
|
|
// Caller can inspect bufferEntry.payload (or other fields) to
|
|
// produce a response that depends on the prior snapshot state.
|
|
return bufferEntry ? "snap-with-entry" : "snap-without-entry";
|
|
});
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: synthesised,
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("applied_to_snapshot"),
|
|
});
|
|
expect(result).toEqual({ kind: "snapshot", response: "snap-with-entry" });
|
|
expect(synthesised).toHaveBeenCalledTimes(1);
|
|
const ctx = synthesised.mock.calls[0]?.[0];
|
|
expect(ctx?.bufferEntry).not.toBeNull();
|
|
// The pre-check entry has the env-matching shape set up by
|
|
// bufferReturning() / preCheckEntry().
|
|
expect(ctx?.bufferEntry?.envId).toBe("env_a");
|
|
expect(ctx?.bufferEntry?.orgId).toBe("org_1");
|
|
});
|
|
|
|
// Symmetric writer-fallback in the `!buffer` short-circuit. Without
|
|
// this, mollifier-disabled deployments (or boot-time buffer init
|
|
// failures) would regress the pre-PR mutation routes — those read
|
|
// from the writer directly, so a fresh PG row was always visible.
|
|
// The replica offload introduced here moves the read to the lagging
|
|
// follower; if the buffer isn't available to disambiguate, we still
|
|
// probe the writer before returning 404.
|
|
it("replica miss + !buffer + writer hit → pgMutation (mollifier-disabled mode recovery)", async () => {
|
|
const row = fakeRun({ friendlyId: "run_1" });
|
|
const pgMutation = vi.fn(async () => "pg-recovered-no-buffer");
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([row]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => null,
|
|
});
|
|
expect(result).toEqual({ kind: "pg", response: "pg-recovered-no-buffer" });
|
|
expect(pgMutation).toHaveBeenCalledWith(row);
|
|
});
|
|
|
|
it("replica miss + !buffer + writer miss → not_found (genuine 404 in mollifier-disabled mode)", async () => {
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([null]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => null,
|
|
});
|
|
expect(result).toEqual({ kind: "not_found" });
|
|
});
|
|
|
|
it("replica miss + buffer not_found + writer miss → not_found", async () => {
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([null]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("not_found"),
|
|
});
|
|
expect(result).toEqual({ kind: "not_found" });
|
|
});
|
|
|
|
it("replica miss + buffer not_found + writer hit → pgMutation (replica-lag recovery)", async () => {
|
|
const row = fakeRun({ friendlyId: "run_1" });
|
|
const pgMutation = vi.fn(async () => "pg-recovered");
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([row]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("not_found"),
|
|
});
|
|
expect(result).toEqual({ kind: "pg", response: "pg-recovered" });
|
|
expect(pgMutation).toHaveBeenCalledWith(row);
|
|
});
|
|
|
|
it("busy → watches buffer through DRAINING, materialises, hits primary exactly once", async () => {
|
|
const row = fakeRun();
|
|
const pgMutation = vi.fn(async () => "pg-after-wait");
|
|
// Writer is read ONCE, only after the buffer reports materialised.
|
|
const writer = fakePrisma([row]);
|
|
const buffer = bufferBusy([entryDraining(), entryDraining(), entryMaterialised()]);
|
|
let nowValue = 0;
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: writer as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
sleep: async (ms) => {
|
|
nowValue += ms;
|
|
},
|
|
now: () => nowValue,
|
|
safetyNetMs: 2000,
|
|
pollStepMs: 20,
|
|
random: () => 0,
|
|
});
|
|
expect(result).toEqual({ kind: "pg", response: "pg-after-wait" });
|
|
expect(pgMutation).toHaveBeenCalledWith(row);
|
|
// One env-pre-check call + 3 busy-wait polls = 4 getEntry reads;
|
|
// primary read exactly once.
|
|
expect(buffer.getEntry).toHaveBeenCalledTimes(4);
|
|
expect(writer.taskRun.findFirst).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("busy → entry deleted by terminal fail, writer finds SYSTEM_FAILURE row → pgMutation", async () => {
|
|
const row = fakeRun();
|
|
const pgMutation = vi.fn(async () => "pg-failed-row");
|
|
const writer = fakePrisma([row]);
|
|
const buffer = bufferBusy([entryDraining(), null]);
|
|
let nowValue = 0;
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: writer as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
sleep: async (ms) => {
|
|
nowValue += ms;
|
|
},
|
|
now: () => nowValue,
|
|
safetyNetMs: 2000,
|
|
pollStepMs: 20,
|
|
random: () => 0,
|
|
});
|
|
expect(result).toEqual({ kind: "pg", response: "pg-failed-row" });
|
|
expect(writer.taskRun.findFirst).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("busy → entry deleted but no PG row (terminal write failed) → not_found", async () => {
|
|
const buffer = bufferBusy([null]);
|
|
const writer = fakePrisma([null]);
|
|
let nowValue = 0;
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: writer as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
sleep: async (ms) => {
|
|
nowValue += ms;
|
|
},
|
|
now: () => nowValue,
|
|
safetyNetMs: 2000,
|
|
pollStepMs: 20,
|
|
random: () => 0,
|
|
});
|
|
expect(result).toEqual({ kind: "not_found" });
|
|
expect(writer.taskRun.findFirst).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("busy → requeued (back to QUEUED) then materialises; doesn't resolve early", async () => {
|
|
const row = fakeRun();
|
|
const pgMutation = vi.fn(async () => "pg-after-requeue");
|
|
const writer = fakePrisma([row]);
|
|
// QUEUED (requeued after a retryable drain error) must NOT be treated
|
|
// as "done" — the run hasn't reached PG. Only the later materialise does.
|
|
const buffer = bufferBusy([entryQueued(), entryDraining(), entryMaterialised()]);
|
|
let nowValue = 0;
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: writer as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
sleep: async (ms) => {
|
|
nowValue += ms;
|
|
},
|
|
now: () => nowValue,
|
|
safetyNetMs: 2000,
|
|
pollStepMs: 20,
|
|
random: () => 0,
|
|
});
|
|
expect(result).toEqual({ kind: "pg", response: "pg-after-requeue" });
|
|
// One env-pre-check + 3 busy-wait polls.
|
|
expect(buffer.getEntry).toHaveBeenCalledTimes(4);
|
|
expect(writer.taskRun.findFirst).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("busy → drainer never resolves (stays DRAINING) → timed_out, primary never touched", async () => {
|
|
const writer = fakePrisma([]);
|
|
const buffer = bufferBusy([entryDraining()]);
|
|
let nowValue = 0;
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: writer as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
sleep: async (ms) => {
|
|
nowValue += ms;
|
|
},
|
|
now: () => nowValue,
|
|
safetyNetMs: 100,
|
|
pollStepMs: 20,
|
|
random: () => 0,
|
|
});
|
|
expect(result).toEqual({ kind: "timed_out" });
|
|
// The whole point: while the run is still draining we never read the primary.
|
|
expect(writer.taskRun.findFirst).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("abort signal during wait → timed_out without further polls", async () => {
|
|
const writer = fakePrisma([]);
|
|
const buffer = bufferBusy([entryDraining(), entryDraining()]);
|
|
const controller = new AbortController();
|
|
let nowValue = 0;
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: writer as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
sleep: async (ms) => {
|
|
nowValue += ms;
|
|
controller.abort();
|
|
},
|
|
now: () => nowValue,
|
|
safetyNetMs: 2000,
|
|
pollStepMs: 20,
|
|
random: () => 0,
|
|
abortSignal: controller.signal,
|
|
});
|
|
expect(result).toEqual({ kind: "timed_out" });
|
|
// One env-pre-check + one busy-wait poll before sleep+abort; primary untouched.
|
|
expect(buffer.getEntry).toHaveBeenCalledTimes(2);
|
|
expect(writer.taskRun.findFirst).toHaveBeenCalledTimes(0);
|
|
});
|
|
|
|
it("replica miss + buffer limit_exceeded → rejected via rejectedResponse builder", async () => {
|
|
const pgMutation = vi.fn(async () => "pg");
|
|
const synthesisedResponse = vi.fn(() => "snap");
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse,
|
|
rejectedResponse: () => "too-many-tags",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("limit_exceeded"),
|
|
});
|
|
expect(result).toEqual({ kind: "rejected", response: "too-many-tags" });
|
|
expect(pgMutation).not.toHaveBeenCalled();
|
|
expect(synthesisedResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("buffer limit_exceeded without a rejectedResponse builder → throws (programmer error)", async () => {
|
|
await expect(
|
|
mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => bufferReturning("limit_exceeded"),
|
|
})
|
|
).rejects.toThrow(/limit_exceeded/);
|
|
});
|
|
|
|
it("replica miss + buffer entry belongs to a different env → not_found (cross-env auth gate)", async () => {
|
|
// Same flow as the applied_to_snapshot test, except the entry's
|
|
// envId doesn't match input.environmentId. mutateWithFallback must
|
|
// refuse the write and return not_found (without leaking that the
|
|
// runId exists in another env), and must NOT call mutateSnapshot.
|
|
const crossEnvEntry: BufferEntry = {
|
|
envId: "env_OTHER",
|
|
orgId: "org_1",
|
|
status: "QUEUED",
|
|
materialised: false,
|
|
} as unknown as BufferEntry;
|
|
const mutateSnapshot = vi.fn(async () => "applied_to_snapshot" as const);
|
|
const buffer = {
|
|
mutateSnapshot,
|
|
getEntry: vi.fn(async () => crossEnvEntry),
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const pgMutation = vi.fn(async () => "pg");
|
|
const synthesisedResponse = vi.fn(() => "snap");
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation,
|
|
synthesisedResponse,
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
});
|
|
expect(result).toEqual({ kind: "not_found" });
|
|
expect(mutateSnapshot).not.toHaveBeenCalled();
|
|
expect(pgMutation).not.toHaveBeenCalled();
|
|
expect(synthesisedResponse).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("replica miss + buffer entry belongs to a different org → not_found (cross-org auth gate)", async () => {
|
|
const crossOrgEntry: BufferEntry = {
|
|
envId: "env_a",
|
|
orgId: "org_OTHER",
|
|
status: "QUEUED",
|
|
materialised: false,
|
|
} as unknown as BufferEntry;
|
|
const mutateSnapshot = vi.fn(async () => "applied_to_snapshot" as const);
|
|
const buffer = {
|
|
mutateSnapshot,
|
|
getEntry: vi.fn(async () => crossOrgEntry),
|
|
} as unknown as MollifierBuffer;
|
|
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => buffer,
|
|
});
|
|
expect(result).toEqual({ kind: "not_found" });
|
|
expect(mutateSnapshot).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("buffer is null (mollifier disabled) → not_found after replica miss", async () => {
|
|
const result = await mutateWithFallback({
|
|
...baseInput,
|
|
pgMutation: async () => "pg",
|
|
synthesisedResponse: () => "snap",
|
|
prismaReplica: fakePrisma([null]) as unknown as typeof DbServer.$replica,
|
|
prismaWriter: fakePrisma([]) as unknown as typeof DbServer.prisma,
|
|
getBuffer: () => null,
|
|
});
|
|
expect(result).toEqual({ kind: "not_found" });
|
|
});
|
|
});
|