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

402 lines
19 KiB
TypeScript

import { context, trace, TraceFlags } from "@opentelemetry/api";
import type { RunEngine } from "@internal/run-engine";
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import type {
MollifierDrainerHandler,
MollifierDrainerTerminalFailureHandler,
} from "@trigger.dev/redis-worker";
import { logger } from "~/services/logger.server";
import { recordRunDebugLog } from "~/v3/eventRepository/index.server";
import { PerformTaskRunAlertsService } from "~/v3/services/alerts/performTaskRunAlerts.server";
import { startSpan } from "~/v3/tracing.server";
import type { MollifierSnapshot } from "./mollifierSnapshot.server";
const tracer = trace.getTracer("mollifier-drainer");
export function isRetryablePgError(err: unknown): boolean {
if (!(err instanceof Error)) return false;
const msg = err.message ?? "";
// Prisma surfaces P1001 ("Can't reach database server") via two
// different error classes — `PrismaClientKnownRequestError` exposes
// it as `err.code`, `PrismaClientInitializationError` exposes it as
// `err.errorCode`. Check both so reconnection-time errors retry
// regardless of which class fires.
const code = (err as { code?: string }).code;
const errorCode = (err as { errorCode?: string }).errorCode;
if (code === "P2024") return true;
if (code === "P1001" || errorCode === "P1001") return true;
if (msg.includes("Can't reach database server")) return true;
if (msg.includes("Connection lost")) return true;
if (msg.includes("ECONNRESET")) return true;
return false;
}
export function createDrainerHandler(deps: {
engine: RunEngine;
prisma: PrismaClientOrTransaction;
}): MollifierDrainerHandler<MollifierSnapshot> {
return async (input) => {
const dwellMs = Date.now() - input.createdAt.getTime();
// Re-attach to the trace started by the caller's mollifier.queued span
// (its traceId + spanId were captured into the snapshot at buffer time).
// Without this the drainer would emit mollifier.drained in a brand-new
// trace and the engine.trigger instrumentation would inherit an empty
// active context — leaving the run-detail page with only the root span.
const snapshotTraceId =
typeof input.payload.traceId === "string" ? input.payload.traceId : undefined;
const snapshotSpanId =
typeof input.payload.spanId === "string" ? input.payload.spanId : undefined;
const parentContext =
snapshotTraceId && snapshotSpanId
? trace.setSpanContext(context.active(), {
traceId: snapshotTraceId,
spanId: snapshotSpanId,
traceFlags: TraceFlags.SAMPLED,
isRemote: true,
})
: context.active();
// Cancel-wins-over-trigger. If a cancel API call landed on this
// entry while it was QUEUED, the snapshot carries `cancelledAt` +
// `cancelReason`. Skip the normal materialise path and write a
// CANCELED PG row directly. The `runCancelled` bus emit is
// suppressed here because a buffered-only run never had a primary
// trace event written for it — the runCancelled handler's
// `cancelRunEvent` lookup would fail and log noise per cancel.
const cancelledAtStr =
typeof input.payload.cancelledAt === "string" ? input.payload.cancelledAt : undefined;
if (cancelledAtStr) {
const cancelReason =
typeof input.payload.cancelReason === "string"
? input.payload.cancelReason
: "Canceled by user";
await context.with(parentContext, async () => {
await startSpan(tracer, "mollifier.drained.cancelled", async (span) => {
span.setAttribute("mollifier.drained", true);
span.setAttribute("mollifier.dwell_ms", dwellMs);
span.setAttribute("mollifier.attempts", input.attempts);
span.setAttribute("mollifier.run_friendly_id", input.runId);
span.setAttribute("mollifier.cancel_bifurcation", true);
span.setAttribute("taskRunId", input.runId);
try {
await deps.engine.createCancelledRun(
{
snapshot: input.payload as any,
cancelledAt: new Date(cancelledAtStr),
cancelReason,
emitRunCancelledEvent: false,
},
deps.prisma
);
} catch (err) {
// createCancelledRun throws a conflict when the normal trigger
// replay path won the race and already materialised a live
// (non-CANCELED) row for this friendlyId. Its contract leaves
// the resolution to us: honour the cancel by actually
// cancelling the now-live run. Letting the conflict propagate
// would instead reach the drainer's terminal-failure path
// (isRetryablePgError() is false for it), buffer.fail() the
// entry, and silently lose the cancellation while the run
// keeps executing.
const isConflict =
err instanceof Error && err.message.startsWith("createCancelledRun conflict");
if (!isConflict) {
// Mirror the SYSTEM_FAILURE fallback the non-cancelled
// trigger path uses below. Without this branch, a
// non-retryable createCancelledRun failure rethrows, the
// drainer's onTerminalFailure handler skips because it
// gates on `cause === "max-attempts-exhausted"` (and the
// outer drainer classifies non-retryable failures with
// `cause: "non-retryable"`), and buffer.fail() deletes
// the entry — leaving NO PG row. The cancellation
// disappears silently from the customer's dashboard.
// Writing a SYSTEM_FAILURE row gives the run a terminal,
// visible state.
if (isRetryablePgError(err)) {
throw err;
}
span.setAttribute(
"mollifier.cancel_terminal_failure_reason",
err instanceof Error ? err.message : String(err)
);
try {
const wrote = await writeMollifierTerminalFailureRow(deps, {
friendlyId: input.runId,
snapshot: input.payload as Record<string, unknown>,
reason: err instanceof Error ? err.message : String(err),
});
if (wrote) return;
} catch (writeErr) {
if (isRetryablePgError(writeErr)) {
span.setAttribute("mollifier.cancel_terminal_write_retryable", true);
throw writeErr;
}
span.setAttribute(
"mollifier.cancel_terminal_write_error",
writeErr instanceof Error ? writeErr.message : String(writeErr)
);
}
throw err;
}
span.setAttribute("mollifier.cancel_conflict", true);
const friendlyId =
typeof input.payload.friendlyId === "string" ? input.payload.friendlyId : input.runId;
await deps.engine.cancelRun({
runId: RunId.fromFriendlyId(friendlyId),
completedAt: new Date(cancelledAtStr),
reason: cancelReason,
});
}
});
});
return;
}
await context.with(parentContext, async () => {
await startSpan(tracer, "mollifier.drained", async (span) => {
span.setAttribute("mollifier.drained", true);
span.setAttribute("mollifier.dwell_ms", dwellMs);
span.setAttribute("mollifier.attempts", input.attempts);
span.setAttribute("mollifier.run_friendly_id", input.runId);
span.setAttribute("taskRunId", input.runId);
let triggerSucceeded = false;
try {
await deps.engine.trigger(input.payload as any, deps.prisma);
triggerSucceeded = true;
} catch (err) {
// The retryable-PG class re-throws so the drainer's outer
// worker loop can `buffer.requeue` (handled in
// `MollifierDrainer.drainOne`). For non-retryable failures we
// write a terminal SYSTEM_FAILURE row to PG via the engine's
// existing `createFailedTaskRun` (used by batch-trigger for
// the same purpose) so the customer sees the run in their
// dashboard / SDK instead of silently losing it when the
// buffer entry TTLs out. If THAT insert also fails (PG truly
// unreachable), rethrow so the drainer's outer catch falls
// through to its existing `buffer.fail` terminal-marker path.
if (isRetryablePgError(err)) {
throw err;
}
const reason = err instanceof Error ? err.message : String(err);
span.setAttribute("mollifier.terminal_failure_reason", reason);
try {
const wrote = await writeMollifierTerminalFailureRow(deps, {
friendlyId: input.runId,
snapshot: input.payload as Record<string, unknown>,
reason,
});
if (!wrote) {
// Snapshot too malformed to even construct a TaskRun row.
// Drainer's outer catch will buffer.fail this entry.
throw err;
}
} catch (writeErr) {
// The terminal SYSTEM_FAILURE write itself failed. If it
// failed because PG is transiently unreachable, rethrow the
// *write* error so the drainer requeues — buffer.fail()ing on
// the original non-retryable error would lose the run with no
// PG row ever landing. Once PG recovers the requeued entry
// writes its failure row and the customer sees it.
if (isRetryablePgError(writeErr)) {
span.setAttribute("mollifier.terminal_write_retryable", true);
throw writeErr;
}
// PG reachable but the write was rejected for another reason
// (genuinely bad snapshot). Rethrow the original trigger error
// so the drainer falls back to buffer.fail.
span.setAttribute(
"mollifier.terminal_write_error",
writeErr instanceof Error ? writeErr.message : String(writeErr)
);
throw err;
}
}
// Admin-only audit trail emitted once engine.trigger has
// landed a PG row. `recordRunDebugLog` flips this to the
// admin-gated debug kind (TaskEventKind.LOG in the PG store /
// DEBUG_EVENT in the ClickHouse store) which the trace view +
// logs download already strip for non-admins
// (`eventRepository.server.ts:108`,
// `resources.runs.$runParam.logs.download.ts:118`).
//
// Placement: emit as a zero-duration marker AT materialisation
// time, not as a back-dated bar spanning the buffered window.
// `engine.trigger` rewrites the run's root span at
// materialisation (it adopts the synth root via traceId/spanId
// carryover but updates start_time to "now"), so the trace
// renderer treats materialisation time as t=0. A back-dated
// event with startTime = bufferedAt would land before that t=0
// and get clipped from the tree. Same pattern as the
// `[engine] QUEUED` markers. The window itself is preserved
// in metadata so admins can read it off the span detail pane.
//
// Best-effort: `recordRunDebugLog` has its own try/catch and
// returns a result, so it never throws into the materialisation
// path. Failures are logged but not surfaced because the
// customer-visible run has already landed.
if (triggerSucceeded) {
const debugResult = await recordRunDebugLog(
RunId.fromFriendlyId(input.runId),
`Mollifier buffered ${dwellMs}ms before materialising`,
{
attributes: {
runId: input.runId,
metadata: {
"mollifier.bufferedAt": input.createdAt.toISOString(),
"mollifier.materialisedAt": new Date().toISOString(),
"mollifier.dwellMs": dwellMs,
"mollifier.attempts": input.attempts,
},
},
parentId: snapshotSpanId,
}
);
if (!debugResult.success && debugResult.code !== "RUN_NOT_FOUND") {
logger.warn("mollifier drainer: failed to record admin debug log", {
runId: input.runId,
code: debugResult.code,
});
}
}
});
});
};
}
// Shared SYSTEM_FAILURE construction used by both terminal paths:
// - non-retryable failure inside the handler (above)
// - retryable failure after maxAttempts inside the drainer's
// `processEntry` (via `createDrainerTerminalFailureHandler`)
//
// Suppresses `runFailed` and enqueues the alert manually — the engine's
// `runFailed` handler calls `completeFailedRunEvent`, which looks up
// the run's primary span. Buffered-only runs never had a primary trace
// event written (the mollifier gate intercepts BEFORE
// `repository.traceEvent` runs), so the lookup always fails and the
// handler logs a systematic `[runFailed] Failed to complete failed
// run event` error per terminal failure. `TriggerFailedTaskService`
// handles the identical situation the same way (see triggerFailedTask
// .server.ts:212 and 324) — pass `emitRunFailedEvent: false` to the
// engine and call `PerformTaskRunAlertsService.enqueue(...)` directly
// so customers' ERROR channels still fire. Alert enqueue is
// best-effort; an alert-side failure is logged but does not bubble up
// (the SYSTEM_FAILURE row landing is the load-bearing customer-visible
// outcome).
//
// Returns the new `TaskRun` on success or `null` when the snapshot was
// so malformed it couldn't even produce an environment — caller decides
// whether to escalate that to `buffer.fail` directly. Throws on any
// other failure so the drainer's retryable/non-retryable disposition
// logic can own the decision.
async function writeMollifierTerminalFailureRow(
deps: { engine: RunEngine; prisma: PrismaClientOrTransaction },
args: { friendlyId: string; snapshot: Record<string, unknown>; reason: string }
) {
const { snapshot } = args;
const env = snapshot.environment as
| {
id: string;
type: any;
project: { id: string };
organization: { id: string };
}
| undefined;
if (!env) return null;
// Extract batch association from the snapshot if present. Without this
// a SYSTEM_FAILURE row for a buffered batch child won't be linked to
// its batch, and the batch parent's completion tracking can hang
// indefinitely waiting on a child that landed but isn't visible to
// the batch.
const rawBatch = snapshot.batch;
const batch =
rawBatch &&
typeof rawBatch === "object" &&
"id" in rawBatch &&
typeof (rawBatch as { id: unknown }).id === "string" &&
"index" in rawBatch &&
typeof (rawBatch as { index: unknown }).index === "number"
? (rawBatch as { id: string; index: number })
: undefined;
const failedRun = await deps.engine.createFailedTaskRun({
friendlyId: args.friendlyId,
environment: env,
taskIdentifier: String(snapshot.taskIdentifier ?? ""),
payload: typeof snapshot.payload === "string" ? snapshot.payload : undefined,
payloadType: typeof snapshot.payloadType === "string" ? snapshot.payloadType : undefined,
error: {
type: "STRING_ERROR",
raw: `Mollifier drainer terminal failure: ${args.reason}`,
},
parentTaskRunId:
typeof snapshot.parentTaskRunId === "string" ? snapshot.parentTaskRunId : undefined,
rootTaskRunId: typeof snapshot.rootTaskRunId === "string" ? snapshot.rootTaskRunId : undefined,
depth: typeof snapshot.depth === "number" ? snapshot.depth : 0,
resumeParentOnCompletion: snapshot.resumeParentOnCompletion === true,
batch,
traceId: typeof snapshot.traceId === "string" ? snapshot.traceId : undefined,
spanId: typeof snapshot.spanId === "string" ? snapshot.spanId : undefined,
taskEventStore:
typeof snapshot.taskEventStore === "string" ? snapshot.taskEventStore : undefined,
queue: typeof snapshot.queue === "string" ? snapshot.queue : undefined,
lockedQueueId: typeof snapshot.lockedQueueId === "string" ? snapshot.lockedQueueId : undefined,
emitRunFailedEvent: false,
});
// Alerts side of `runFailed` — the engine emit was suppressed above
// so we don't create an orphan trace event; enqueue the alert
// directly so customers' ERROR channels still see the failure.
// Best-effort, mirroring TriggerFailedTaskService.
try {
await PerformTaskRunAlertsService.enqueue(failedRun.id);
} catch (alertsError) {
logger.warn("writeMollifierTerminalFailureRow: alert enqueue failed", {
friendlyId: args.friendlyId,
error: alertsError instanceof Error ? alertsError.message : String(alertsError),
});
}
return failedRun;
}
// Drainer-side terminal-failure callback. Fires from
// `MollifierDrainer.processEntry` BEFORE `buffer.fail()` on any path
// where the in-handler write didn't already land — currently the
// `cause: "max-attempts-exhausted"` case for retryable PG errors. Writes
// the same SYSTEM_FAILURE row the non-retryable handler path writes
// inline (via the shared `writeMollifierTerminalFailureRow` helper) so
// the customer-visible behaviour is identical regardless of how the
// failure was classified.
//
// Re-throws retryable PG errors so the drainer requeues — buffer.fail()ing
// here would still lose the run if PG is genuinely unreachable. Throwing
// anything else falls through to buffer.fail to avoid an infinite loop on
// a genuinely bad snapshot (the drainer logs it).
export function createDrainerTerminalFailureHandler(deps: {
engine: RunEngine;
prisma: PrismaClientOrTransaction;
}): MollifierDrainerTerminalFailureHandler<MollifierSnapshot> {
return async (input) => {
// The handler's own non-retryable terminal path has already written
// the SYSTEM_FAILURE row before it throws non-retryable. Only the
// retryable-exhausted path reaches us with no row written yet — gate
// on `cause` to avoid double-writing for non-retryable failures.
if (input.cause !== "max-attempts-exhausted") return;
await startSpan(tracer, "mollifier.drained.terminal_failure", async (span) => {
span.setAttribute("mollifier.drained", false);
span.setAttribute("mollifier.attempts", input.attempts);
span.setAttribute("mollifier.run_friendly_id", input.runId);
span.setAttribute("mollifier.terminal_failure_cause", input.cause);
span.setAttribute("mollifier.terminal_failure_reason", input.error.message);
span.setAttribute("taskRunId", input.runId);
await writeMollifierTerminalFailureRow(deps, {
friendlyId: input.runId,
snapshot: input.payload as Record<string, unknown>,
reason: input.error.message,
});
});
};
}