266 lines
11 KiB
TypeScript
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;
|
|
}
|
|
}
|