Files
triggerdotdev--trigger.dev/apps/webapp/app/v3/mollifier/readFallback.server.ts
T
2026-07-13 13:32:57 +08:00

266 lines
11 KiB
TypeScript

import type { MollifierBuffer } from "@trigger.dev/redis-worker";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import { IdempotencyKeyOptionsSchema } from "@trigger.dev/core/v3/schemas";
import type { z } from "zod";
import { logger } from "~/services/logger.server";
import { deserialiseMollifierSnapshot } from "./mollifierSnapshot.server";
import { getMollifierBuffer } from "./mollifierBuffer.server";
export type ReadFallbackInput = {
runId: string;
environmentId: string;
organizationId: string;
};
export type SyntheticRun = {
// Snapshot-derived TaskRun primary key. Used by ReplayTaskRunService
// for logging and by callers passing this object where a TaskRun is
// expected (cast). Derived deterministically from `friendlyId`.
id: string;
friendlyId: string;
status: "QUEUED" | "FAILED" | "CANCELED";
// Set when the customer cancelled the run via the dashboard or API
// while it was buffered. The drainer's cancel bifurcation reads this
// on next pop and writes a CANCELED PG row directly (skipping
// materialisation). Reflected back into the UI by the synthesised
// SpanRun so the run-detail page shows the cancelled state even before
// the drainer materialises it.
cancelledAt: Date | undefined;
cancelReason: string | undefined;
// Reschedule patch (`set_delay`) writes `delayUntil` into the snapshot.
// Surfacing it on SyntheticRun lets the retrieve-run shape reflect the
// pending delay before the drainer materialises the PG row.
delayUntil: Date | undefined;
taskIdentifier: string | undefined;
createdAt: Date;
payload: unknown;
payloadType: string | undefined;
metadata: unknown;
metadataType: string | undefined;
// Seed-metadata mirrors what `triggerTask.server.ts` writes into the
// snapshot: the original metadataPacket data preserved separately from
// any later customer mutations. ReplayTaskRunService uses these to
// rebuild the replay's metadata.
seedMetadata: string | undefined;
seedMetadataType: string | undefined;
idempotencyKey: string | undefined;
// Surfaced for the cached-hit expiration check in IdempotencyKeyConcern.
// The PG-resident path enforces this (clears key, allows new run when
// expired). For buffered runs the snapshot carries the same field — we
// expose it here so the cached-hit branch can apply the same check
// rather than indefinitely returning the buffered run's id.
idempotencyKeyExpiresAt: Date | undefined;
// `{ key, scope }` object form, matching how the SDK serialises and PG
// stores it. Previously typed as `string[]` (legacy/incorrect — Prisma
// is `Json?` carrying the schema-shaped object). `getUserProvidedIdempotencyKey`
// and `extractIdempotencyKeyScope` both parse via the same Zod schema;
// they returned `undefined` for the array-shape, which silently
// demoted the response to surface the hash instead of the user-
// provided key for buffered runs — a contract divergence from
// PG-resident runs. See the regression test in `mollifierReadFallback.test.ts`.
idempotencyKeyOptions: z.infer<typeof IdempotencyKeyOptionsSchema> | undefined;
isTest: boolean;
depth: number;
ttl: string | undefined;
tags: string[];
// Mirror of `tags` under the PG field name. ReplayTaskRunService reads
// `existingTaskRun.runTags`; both names are kept here so a synthetic
// run can be passed wherever the PG-shape `runTags` is expected.
runTags: string[];
lockedToVersion: string | undefined;
resumeParentOnCompletion: boolean;
parentTaskRunId: string | undefined;
// Allocated at gate-accept time and embedded in the snapshot so the run's
// trace is continuous from QUEUED-in-buffer through executing post-drain.
traceId: string | undefined;
spanId: string | undefined;
parentSpanId: string | undefined;
// Replay-relevant fields populated from the engine-trigger snapshot.
// ReplayTaskRunService reads each of these from the existing TaskRun;
// when the original lives in the buffer we synthesise them here.
runtimeEnvironmentId: string | undefined;
engine: "V2";
workerQueue: string | undefined;
region: string | undefined;
queue: string | undefined;
concurrencyKey: string | undefined;
machinePreset: string | undefined;
realtimeStreamsVersion: string | undefined;
// Additional snapshot-sourced fields used when synthesising a SpanRun
// for the dashboard's right-side details panel. All optional because
// older snapshots may not carry them.
maxAttempts: number | undefined;
maxDurationInSeconds: number | undefined;
replayedFromTaskRunFriendlyId: string | undefined;
annotations: unknown;
traceContext: unknown;
scheduleId: string | undefined;
batchId: string | undefined;
parentTaskRunFriendlyId: string | undefined;
rootTaskRunFriendlyId: string | undefined;
error?: { code: string; message: string };
};
export type ReadFallbackDeps = {
getBuffer?: () => MollifierBuffer | null;
};
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string")
? (value as string[])
: [];
}
function asDate(value: unknown): Date | undefined {
const raw = asString(value);
if (!raw) return undefined;
const parsed = new Date(raw);
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
}
// Snapshot ids are written by engine.trigger as INTERNAL ids (cuids); the
// SyntheticRun contract exposes friendlyIds. `RunId.toFriendlyId` is
// already used for the synthetic run's own id (line 155); reuse it for
// parent/root so consumers see the same shape as the PG path.
function internalRunIdToFriendlyId(internalId: string | undefined): string | undefined {
if (!internalId) return undefined;
return RunId.toFriendlyId(internalId);
}
export async function findRunByIdWithMollifierFallback(
input: ReadFallbackInput,
deps: ReadFallbackDeps = {}
): Promise<SyntheticRun | null> {
const buffer = (deps.getBuffer ?? getMollifierBuffer)();
if (!buffer) return null;
try {
const entry = await buffer.getEntry(input.runId);
if (!entry) return null;
if (entry.envId !== input.environmentId || entry.orgId !== input.organizationId) {
logger.warn("mollifier read-fallback auth mismatch", {
runId: input.runId,
callerEnvId: input.environmentId,
callerOrgId: input.organizationId,
});
return null;
}
const snapshot = deserialiseMollifierSnapshot(entry.payload);
// Parse via the canonical schema (`{ key: string, scope: "run" |
// "attempt" | "global" }`) rather than the legacy Array.isArray
// check. The SDK and Prisma both store this as an object; the array
// form never matches, so a buffered run's response previously fell
// back to the server-side hash in `getUserProvidedIdempotencyKey`
// instead of the customer-supplied key — diverging from how
// materialised runs render the same field.
const idempotencyKeyOptionsParsed = IdempotencyKeyOptionsSchema.safeParse(
snapshot.idempotencyKeyOptions
);
const idempotencyKeyOptions = idempotencyKeyOptionsParsed.success
? idempotencyKeyOptionsParsed.data
: undefined;
const tags = asStringArray(snapshot.tags);
const environment =
snapshot.environment && typeof snapshot.environment === "object"
? (snapshot.environment as Record<string, unknown>)
: undefined;
const cancelledAt = asDate(snapshot.cancelledAt);
const cancelReason = asString(snapshot.cancelReason);
let status: SyntheticRun["status"] = "QUEUED";
if (cancelledAt) {
status = "CANCELED";
} else if (entry.status === "FAILED") {
status = "FAILED";
}
const delayUntil = asDate(snapshot.delayUntil);
return {
id: RunId.fromFriendlyId(entry.runId),
friendlyId: entry.runId,
status,
cancelledAt,
cancelReason,
delayUntil,
taskIdentifier: asString(snapshot.taskIdentifier),
createdAt: entry.createdAt,
payload: snapshot.payload,
payloadType: asString(snapshot.payloadType),
metadata: snapshot.metadata,
metadataType: asString(snapshot.metadataType),
seedMetadata: asString(snapshot.seedMetadata),
seedMetadataType: asString(snapshot.seedMetadataType),
idempotencyKey: asString(snapshot.idempotencyKey),
idempotencyKeyExpiresAt: asDate(snapshot.idempotencyKeyExpiresAt),
idempotencyKeyOptions,
isTest: snapshot.isTest === true,
depth: typeof snapshot.depth === "number" ? snapshot.depth : 0,
ttl: asString(snapshot.ttl),
tags,
runTags: tags,
lockedToVersion: asString(snapshot.taskVersion),
resumeParentOnCompletion: snapshot.resumeParentOnCompletion === true,
parentTaskRunId: asString(snapshot.parentTaskRunId),
traceId: asString(snapshot.traceId),
spanId: asString(snapshot.spanId),
parentSpanId: asString(snapshot.parentSpanId),
runtimeEnvironmentId: asString(environment?.id) ?? entry.envId,
engine: "V2",
workerQueue: asString(snapshot.workerQueue),
region: asString(snapshot.region),
queue: asString(snapshot.queue),
concurrencyKey: asString(snapshot.concurrencyKey),
machinePreset: asString(snapshot.machine),
realtimeStreamsVersion: asString(snapshot.realtimeStreamsVersion),
maxAttempts: typeof snapshot.maxAttempts === "number" ? snapshot.maxAttempts : undefined,
maxDurationInSeconds:
typeof snapshot.maxDurationInSeconds === "number"
? snapshot.maxDurationInSeconds
: undefined,
replayedFromTaskRunFriendlyId: asString(snapshot.replayedFromTaskRunFriendlyId),
annotations: snapshot.annotations,
traceContext: snapshot.traceContext,
scheduleId: asString(snapshot.scheduleId),
// The engine.trigger input embeds the batch as `{ id, index }` (see
// triggerTask.server.ts #buildEngineTriggerInput), not as a flat
// `batchId`. The nested `id` is the batch's internal cuid — the same
// value PG stores in `TaskRun.batchId` — so callers reconstruct the
// friendly id via `BatchId.toFriendlyId` exactly as the PG path does.
batchId: asString((snapshot.batch as { id?: unknown } | undefined)?.id),
// The snapshot only carries the INTERNAL parent/root ids
// (`parentTaskRunId` / `rootTaskRunId` — what engine.trigger consumes),
// not the friendlyIds the SyntheticRun contract expects. Convert
// internal → friendly here so consumers don't have to special-case
// the buffered path.
parentTaskRunFriendlyId: internalRunIdToFriendlyId(asString(snapshot.parentTaskRunId)),
rootTaskRunFriendlyId: internalRunIdToFriendlyId(asString(snapshot.rootTaskRunId)),
error: entry.lastError,
};
} catch (err) {
logger.error("mollifier read-fallback errored — fail-open to null", {
runId: input.runId,
err: err instanceof Error ? err.message : String(err),
});
return null;
}
}