Files
2026-07-13 13:32:57 +08:00

573 lines
20 KiB
TypeScript

import { describe, expect, it, vi } from "vitest";
import { trace } from "@opentelemetry/api";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
vi.mock("~/db.server", () => ({
prisma: {},
$replica: {},
}));
// `writeMollifierTerminalFailureRow` enqueues a PerformTaskRunAlertsService
// after writing the SYSTEM_FAILURE row (mirrors TriggerFailedTaskService).
// In production that enqueues into the alerts redis-worker; the test
// environment has no redis-worker, so the real call hangs the tick out
// to its 5s vitest timeout. Stub `enqueue` to a resolved no-op so the
// handler's best-effort try/catch sees a clean success path.
vi.mock("~/v3/services/alerts/performTaskRunAlerts.server", () => ({
PerformTaskRunAlertsService: {
enqueue: vi.fn(async () => undefined),
},
}));
// The drainer calls `recordRunDebugLog` after a successful engine.trigger
// to emit an admin-only LOG-kind event encoding the buffered window.
// The real implementation imports the configured event repository (prisma
// + clickhouse + env), which has heavy side-effects on first import.
// Stub it to a vi.fn so the unit tests can assert call shape without
// dragging the whole eventRepository graph into webapp test setup.
// `vi.hoisted` is required because `vi.mock` factories are hoisted above
// regular `const`s — referencing a top-level variable from inside the
// factory otherwise fires `Cannot access 'X' before initialization`.
const { recordRunDebugLogMock } = vi.hoisted(() => ({
recordRunDebugLogMock: vi.fn(async () => ({ success: true as const })),
}));
vi.mock("~/v3/eventRepository/index.server", () => ({
recordRunDebugLog: recordRunDebugLogMock,
}));
import {
createDrainerHandler,
isRetryablePgError,
} from "~/v3/mollifier/mollifierDrainerHandler.server";
describe("isRetryablePgError", () => {
it("returns true for P2024 (connection pool timeout)", () => {
const err = Object.assign(new Error("Timed out fetching a new connection"), {
code: "P2024",
});
expect(isRetryablePgError(err)).toBe(true);
});
it("returns true for generic connection-lost messages", () => {
expect(isRetryablePgError(new Error("Connection lost"))).toBe(true);
expect(isRetryablePgError(new Error("Can't reach database server"))).toBe(true);
});
it("returns false for validation errors", () => {
expect(isRetryablePgError(new Error("Invalid payload"))).toBe(false);
});
it("returns false for non-Error inputs", () => {
expect(isRetryablePgError("string error")).toBe(false);
expect(isRetryablePgError({ message: "object" })).toBe(false);
});
});
describe("createDrainerHandler", () => {
it("invokes engine.trigger with the deserialised snapshot", async () => {
const trigger = vi.fn(async () => ({ friendlyId: "run_x" }));
const handler = createDrainerHandler({
engine: { trigger } as any,
prisma: {} as any,
});
await handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t", payload: "{}" },
attempts: 0,
createdAt: new Date(),
} as any);
expect(trigger).toHaveBeenCalledOnce();
const callArg = trigger.mock.calls[0][0] as { taskIdentifier: string };
expect(callArg.taskIdentifier).toBe("t");
});
it("re-attaches the snapshot's traceId so engine.trigger inherits the original trace", async () => {
// Captures the active traceId at the moment engine.trigger is invoked.
// Without context propagation it would be a fresh traceId, leaving the
// run-detail page with only the root span.
let observedTraceId: string | undefined;
const trigger = vi.fn(async () => {
observedTraceId = trace.getActiveSpan()?.spanContext().traceId;
return { friendlyId: "run_x" };
});
const handler = createDrainerHandler({
engine: { trigger } as any,
prisma: {} as any,
});
const snapshotTraceId = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
const snapshotSpanId = "bbbbbbbbbbbbbbbb";
await handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: {
taskIdentifier: "t",
traceId: snapshotTraceId,
spanId: snapshotSpanId,
},
attempts: 0,
createdAt: new Date(),
} as any);
expect(observedTraceId).toBe(snapshotTraceId);
});
it("rethrows retryable PG errors so MollifierDrainer requeues the entry", async () => {
const err = new Error("Can't reach database server");
const trigger = vi.fn(async () => {
throw err;
});
const createFailedTaskRun = vi.fn();
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t" },
attempts: 0,
createdAt: new Date(),
} as any)
).rejects.toThrow("Can't reach database server");
// Retryable: we do NOT write a SYSTEM_FAILURE row, the entry should
// be requeued for another shot.
expect(createFailedTaskRun).not.toHaveBeenCalled();
});
const envFixture = {
id: "env_a",
type: "DEVELOPMENT",
project: { id: "proj_1" },
organization: { id: "org_1" },
};
it("writes a SYSTEM_FAILURE PG row when engine.trigger fails non-retryably", async () => {
const trigger = vi.fn(async () => {
throw new Error("validation failed: payload too large");
});
const createFailedTaskRun = vi.fn(async () => ({
id: "internal",
friendlyId: "run_x",
}));
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t", environment: envFixture },
attempts: 0,
createdAt: new Date(),
} as any)
).resolves.toBeUndefined();
expect(trigger).toHaveBeenCalledOnce();
expect(createFailedTaskRun).toHaveBeenCalledOnce();
const arg = createFailedTaskRun.mock.calls[0][0] as { error: { raw: string } };
expect(arg.error.raw).toContain("validation failed");
});
it("propagates the batch association into createFailedTaskRun (so batch parents don't hang on missing children)", async () => {
// Devin's ANALYSIS report on PR #3754: the terminal-failure path
// extracts most snapshot fields (parentTaskRunId, rootTaskRunId,
// depth, etc.) but dropped `batch`. If the original trigger was
// part of a batch, the SYSTEM_FAILURE row isn't associated with
// the batch, so the batch parent's completion-tracking can hang
// indefinitely waiting on a child that landed but isn't linked.
const trigger = vi.fn(async () => {
throw new Error("validation failed: payload too large");
});
const createFailedTaskRun = vi.fn(async () => ({
id: "internal",
friendlyId: "run_x",
}));
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: "run_batched",
envId: "env_a",
orgId: "org_1",
payload: {
taskIdentifier: "t",
environment: envFixture,
batch: { id: "batch_xyz", index: 7 },
},
attempts: 0,
createdAt: new Date(),
} as any)
).resolves.toBeUndefined();
expect(createFailedTaskRun).toHaveBeenCalledOnce();
const arg = createFailedTaskRun.mock.calls[0][0] as {
batch?: { id: string; index: number };
};
expect(arg.batch).toEqual({ id: "batch_xyz", index: 7 });
});
it("rethrows the original error when createFailedTaskRun also fails (PG genuinely unreachable)", async () => {
const triggerErr = new Error("engine rejected the snapshot");
const trigger = vi.fn(async () => {
throw triggerErr;
});
const createFailedTaskRun = vi.fn(async () => {
throw new Error("connection refused");
});
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t", environment: envFixture },
attempts: 0,
createdAt: new Date(),
} as any)
).rejects.toThrow("engine rejected the snapshot");
// Drainer's outer drainOne loop now decides retry vs buffer.fail.
expect(createFailedTaskRun).toHaveBeenCalledOnce();
});
it("calls createCancelledRun with emitRunCancelledEvent: false (suppresses orphan trace-event log noise)", async () => {
// Buffered-only runs never had a primary trace event written for
// them — the mollifier gate skipped `repository.traceEvent` since
// the run hadn't materialised in PG yet. The `runCancelled` handler
// would log `[runCancelled] Failed to cancel run event` for every
// cancelled buffered run if we let the emit fire. Suppress it.
const friendlyId = RunId.generate().friendlyId;
const createCancelledRun = vi.fn(async () => ({
id: "internal",
friendlyId,
status: "CANCELED",
}));
const handler = createDrainerHandler({
engine: { createCancelledRun } as any,
prisma: {} as any,
});
await handler({
runId: friendlyId,
envId: "env_a",
orgId: "org_1",
payload: {
friendlyId,
taskIdentifier: "t",
environment: envFixture,
cancelledAt: new Date().toISOString(),
cancelReason: "Canceled by user",
},
attempts: 0,
createdAt: new Date(),
} as any);
expect(createCancelledRun).toHaveBeenCalledOnce();
const arg = createCancelledRun.mock.calls[0][0] as {
emitRunCancelledEvent?: boolean;
};
expect(arg.emitRunCancelledEvent).toBe(false);
});
it("honours the cancel when a buffered cancel races a materialised non-CANCELED row", async () => {
// Cancel-wins-over-trigger. If the normal trigger
// replay path materialised a live PENDING row before the cancel
// bifurcation drained, engine.createCancelledRun throws a conflict —
// its documented contract is that "the caller must decide between
// engine.cancelRun() and skipping". The drainer handler must honour
// the cancel intent by actually cancelling the now-live run; otherwise
// the conflict propagates, isRetryablePgError() returns false, and the
// drainer buffer.fail()s the entry — silently losing the cancellation
// while the run keeps executing.
const friendlyId = RunId.generate().friendlyId;
const createCancelledRun = vi.fn(async () => {
throw new Error(`createCancelledRun conflict: existing run ${friendlyId} has status PENDING`);
});
const cancelRun = vi.fn(async () => ({ alreadyFinished: false }));
const handler = createDrainerHandler({
engine: { createCancelledRun, cancelRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: friendlyId,
envId: "env_a",
orgId: "org_1",
payload: {
friendlyId,
taskIdentifier: "t",
environment: envFixture,
cancelledAt: new Date().toISOString(),
cancelReason: "Canceled by user",
},
attempts: 0,
createdAt: new Date(),
} as any)
).resolves.toBeUndefined();
// The live run is actually cancelled, by its internal id.
expect(cancelRun).toHaveBeenCalledOnce();
expect(cancelRun.mock.calls[0][0].runId).toBe(RunId.fromFriendlyId(friendlyId));
});
it("requeues on a transient PG outage during the SYSTEM_FAILURE fallback write", async () => {
// engine.trigger failed non-retryably, so we try to write a terminal
// SYSTEM_FAILURE row. If THAT write fails because PG is transiently
// unreachable, rethrowing the *original* non-retryable error makes the
// drainer buffer.fail() the entry — losing the run with no PG row ever
// landing. Rethrow the retryable write error instead so the drainer
// requeues; once PG recovers the failure row lands and the customer
// sees it.
const trigger = vi.fn(async () => {
throw new Error("validation failed: payload too large");
});
const createFailedTaskRun = vi.fn(async () => {
throw new Error("Can't reach database server");
});
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t", environment: envFixture },
attempts: 0,
createdAt: new Date(),
} as any)
).rejects.toThrow("Can't reach database server");
});
it("writes a SYSTEM_FAILURE row when createCancelledRun fails non-retryably (cancel bifurcation)", async () => {
// Without this guard a non-conflict, non-retryable failure from
// createCancelledRun rethrows out of the handler. The drainer's
// onTerminalFailure gates on cause==="max-attempts-exhausted" and
// skips "non-retryable", so buffer.fail() deletes the entry with
// no PG row written — the cancellation disappears silently.
// Mirror the non-cancel path's SYSTEM_FAILURE fallback so the
// customer always sees a terminal row.
const friendlyId = RunId.generate().friendlyId;
const cancelErr = new Error("validation failed: bad cancel snapshot");
const createCancelledRun = vi.fn(async () => {
throw cancelErr;
});
const createFailedTaskRun = vi.fn(async () => ({ id: "internal_x" }));
const handler = createDrainerHandler({
engine: { createCancelledRun, createFailedTaskRun } as any,
prisma: {} as any,
});
await handler({
runId: friendlyId,
envId: "env_a",
orgId: "org_1",
payload: {
friendlyId,
taskIdentifier: "t",
environment: envFixture,
cancelledAt: new Date().toISOString(),
cancelReason: "Canceled by user",
},
attempts: 0,
createdAt: new Date(),
} as any);
// SYSTEM_FAILURE row was written via the shared helper. Handler
// returns cleanly so the drainer ACKs the entry instead of
// buffer.fail()ing it.
expect(createFailedTaskRun).toHaveBeenCalledOnce();
expect(createFailedTaskRun.mock.calls[0][0].friendlyId).toBe(friendlyId);
expect(createFailedTaskRun.mock.calls[0][0].error.raw).toContain(
"validation failed: bad cancel snapshot"
);
});
it("requeues when createCancelledRun fails with a retryable PG error (cancel bifurcation)", async () => {
// Retryable PG failures must rethrow so the drainer requeues the
// entry — writing a SYSTEM_FAILURE row when PG is transiently
// unreachable would still fail. The drainer's existing retry loop
// handles the requeue.
const friendlyId = RunId.generate().friendlyId;
const cancelErr = new Error("Can't reach database server");
const createCancelledRun = vi.fn(async () => {
throw cancelErr;
});
const createFailedTaskRun = vi.fn();
const handler = createDrainerHandler({
engine: { createCancelledRun, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: friendlyId,
envId: "env_a",
orgId: "org_1",
payload: {
friendlyId,
taskIdentifier: "t",
environment: envFixture,
cancelledAt: new Date().toISOString(),
cancelReason: "Canceled by user",
},
attempts: 0,
createdAt: new Date(),
} as any)
).rejects.toThrow("Can't reach database server");
expect(createFailedTaskRun).not.toHaveBeenCalled();
});
it("rethrows the original error when the snapshot lacks an environment block", async () => {
const triggerErr = new Error("engine rejected the snapshot");
const trigger = vi.fn(async () => {
throw triggerErr;
});
const createFailedTaskRun = vi.fn();
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await expect(
handler({
runId: "run_x",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t" /* no environment */ },
attempts: 0,
createdAt: new Date(),
} as any)
).rejects.toThrow("engine rejected the snapshot");
expect(createFailedTaskRun).not.toHaveBeenCalled();
});
it("emits an admin-only LOG-kind event with the buffered window after engine.trigger succeeds", async () => {
// The drainer's audit trail rides the existing TaskEventKind.LOG
// filter pattern (`eventRepository.server.ts:108` + `logs.download.ts:118`)
// — admins see the buffered window in the trace; non-admins don't.
recordRunDebugLogMock.mockClear();
const trigger = vi.fn(async () => ({ friendlyId: "run_z" }));
const handler = createDrainerHandler({
engine: { trigger } as any,
prisma: {} as any,
});
const bufferedAt = new Date(Date.now() - 4_000);
await handler({
runId: "run_z",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t", spanId: "snapspan", traceId: "snaptrace" },
attempts: 2,
createdAt: bufferedAt,
} as any);
expect(recordRunDebugLogMock).toHaveBeenCalledOnce();
const [callRunId, message, options] = recordRunDebugLogMock.mock.calls[0] as [
string,
string,
any,
];
// Internal cuid derived from the friendlyId, mirroring what
// `findRunForEventCreation` queries on.
expect(callRunId).toBe("z");
expect(message).toMatch(/Mollifier buffered \d+ms before materialising/);
// Emitted as a marker at materialisation time (no `startTime` /
// `duration` overrides) — engine.trigger has just rewritten the
// root span's start_time to "now", so back-dating the event would
// clip it off-screen in the trace renderer. The historical window
// is preserved in metadata so admins can still read it.
expect(options.startTime).toBeUndefined();
expect(options.duration).toBeUndefined();
expect(options.parentId).toBe("snapspan");
expect(options.attributes.metadata["mollifier.bufferedAt"]).toBe(bufferedAt.toISOString());
expect(options.attributes.metadata["mollifier.attempts"]).toBe(2);
expect(options.attributes.metadata["mollifier.dwellMs"]).toBeGreaterThan(0);
});
it("does NOT emit the admin LOG event when engine.trigger fails non-retryably", async () => {
// The audit trail is for runs that actually materialised. On a
// terminal SYSTEM_FAILURE path the customer-visible outcome is the
// failure row; emitting a "buffered for Xms" event next to it would
// imply the buffered window completed normally.
recordRunDebugLogMock.mockClear();
const trigger = vi.fn(async () => {
throw new Error("engine rejected the snapshot");
});
const createFailedTaskRun = vi.fn(async () => ({ id: "internal" }));
const handler = createDrainerHandler({
engine: { trigger, createFailedTaskRun } as any,
prisma: {} as any,
});
await handler({
runId: "run_z",
envId: "env_a",
orgId: "org_1",
payload: { taskIdentifier: "t", environment: envFixture },
attempts: 0,
createdAt: new Date(),
} as any);
expect(recordRunDebugLogMock).not.toHaveBeenCalled();
});
it("does NOT emit the admin LOG event on the cancel-bifurcation path", async () => {
// Cancel-bifurcation writes a CANCELED row directly without calling
// engine.trigger. There's no buffered-then-materialised window to
// describe — the run never ran.
recordRunDebugLogMock.mockClear();
const friendlyId = RunId.generate().friendlyId;
const createCancelledRun = vi.fn(async () => ({
id: "internal",
friendlyId,
status: "CANCELED",
}));
const handler = createDrainerHandler({
engine: { createCancelledRun } as any,
prisma: {} as any,
});
await handler({
runId: friendlyId,
envId: "env_a",
orgId: "org_1",
payload: {
friendlyId,
taskIdentifier: "t",
environment: envFixture,
cancelledAt: new Date().toISOString(),
cancelReason: "Canceled by user",
},
attempts: 0,
createdAt: new Date(),
} as any);
expect(recordRunDebugLogMock).not.toHaveBeenCalled();
});
});