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

230 lines
9.8 KiB
TypeScript

import type {
BufferEntry,
MollifierBuffer,
MutateSnapshotResult,
SnapshotPatch,
} from "@trigger.dev/redis-worker";
import type { TaskRun } from "@trigger.dev/database";
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
import { prisma, $replica } from "~/db.server";
import { runStore } from "~/v3/runStore.server";
import { logger } from "~/services/logger.server";
import { getMollifierBuffer } from "./mollifierBuffer.server";
// Wait/retry knobs. Exported for tests.
export const DEFAULT_SAFETY_NET_MS = 2_000;
// Initial gap between buffer polls; grows by BACKOFF_FACTOR up to
// DEFAULT_MAX_POLL_STEP_MS so a slow drain doesn't poll at a tight fixed
// cadence for the whole safety-net budget.
export const DEFAULT_POLL_STEP_MS = 20;
export const DEFAULT_MAX_POLL_STEP_MS = 250;
export const DEFAULT_BACKOFF_FACTOR = 1.7;
export type MutateWithFallbackInput<TResponse> = {
runId: string;
environmentId: string;
organizationId: string;
bufferPatch: SnapshotPatch;
// Called when a PG row exists (either replica-hit or post-wait writer-hit).
// Receives the full TaskRun shape and returns the customer-visible body.
pgMutation: (pgRow: TaskRun) => Promise<TResponse>;
// Called when the patch landed cleanly on the buffer snapshot. The
// drainer will see the patched payload on its next pop. Receives the
// pre-mutation snapshot entry (the one fetched for the env auth
// check above) so the caller can compute response details that
// depend on the prior state — e.g. the tags route needs to dedup
// against the existing tags to report an accurate `newTags` count
// matching the PG path, without an extra Redis round-trip.
// `bufferEntry` is `null` in the rare race where the entry didn't
// exist at pre-check time but appeared before `mutateSnapshot`.
synthesisedResponse: (ctx: { bufferEntry: BufferEntry | null }) => TResponse | Promise<TResponse>;
// Called when the buffer rejected the patch as invalid (e.g. an
// `append_tags` patch carrying `maxTags` would exceed the cap). Required
// only by callers that send a rejectable patch; the helper throws if the
// buffer reports a rejection and no builder was supplied. Receives the
// same `bufferEntry` context as `synthesisedResponse` so a rejection
// message can reference the prior state if useful.
rejectedResponse?: (ctx: { bufferEntry: BufferEntry | null }) => TResponse | Promise<TResponse>;
abortSignal?: AbortSignal;
// Override defaults for tests.
safetyNetMs?: number;
pollStepMs?: number;
maxPollStepMs?: number;
backoffFactor?: number;
// Test injection.
getBuffer?: () => MollifierBuffer | null;
prismaWriter?: PrismaClientOrTransaction;
prismaReplica?: PrismaReplicaClient;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
// Jitter source; defaults to Math.random. Inject `() => 0` for
// deterministic poll timing in tests.
random?: () => number;
};
export type MutateWithFallbackOutcome<TResponse> =
| { kind: "pg"; response: TResponse }
| { kind: "snapshot"; response: TResponse }
| { kind: "rejected"; response: TResponse }
| { kind: "not_found" }
| { kind: "timed_out" };
// PG-first → buffer mutateSnapshot → wait-and-bounce. The
// caller decides how to translate the outcome into an HTTP response —
// this helper never throws Response objects so it remains route-agnostic
// and unit-testable in isolation.
export async function mutateWithFallback<TResponse>(
input: MutateWithFallbackInput<TResponse>
): Promise<MutateWithFallbackOutcome<TResponse>> {
const replica = input.prismaReplica ?? $replica;
const writer = input.prismaWriter ?? prisma;
const buffer = (input.getBuffer ?? getMollifierBuffer)();
const sleep = input.sleep ?? defaultSleep;
const now = input.now ?? Date.now;
// Path 1 — PG is already canonical.
const replicaRow = await findRunInPg(replica, input.runId, input.environmentId);
if (replicaRow) {
const response = await input.pgMutation(replicaRow);
return { kind: "pg", response };
}
if (!buffer) {
// No buffer configured (mollifier disabled or boot-time error). The
// pre-PR mutation routes read from the writer directly, so a freshly-
// created PG row was always visible regardless of replication lag.
// Now that the read moved to the replica (line 87) for the offload,
// a `!buffer` short-circuit would regress: a real PG row + replica
// lag would return 404. Mirror the writer-disambiguation block below
// (line 148, the buffer-says-not-found path) so degraded mode
// (mollifier disabled) still matches pre-PR mutation behaviour.
const writerRow = await findRunInPg(writer, input.runId, input.environmentId);
if (writerRow) {
const response = await input.pgMutation(writerRow);
return { kind: "pg", response };
}
return { kind: "not_found" };
}
// Env-scoped authorization for the buffer path. The replica/writer
// lookups above are already env-scoped via findRunInPg; this closes
// the same gap on the buffer side so a caller authed in env A can't
// mutate a buffered run that belongs to env B (or a different org)
// by guessing its friendlyId. Non-atomic w.r.t. the mutateSnapshot
// call below, but the TOCTOU is benign: runIds are globally unique,
// so a cross-env entry can't suddenly appear after a same-env check.
// A genuinely-missing entry (entry === null) falls through and is
// handled by the existing not_found / writer-recovery path below.
const entryForAuth = await buffer.getEntry(input.runId);
if (
entryForAuth &&
(entryForAuth.envId !== input.environmentId || entryForAuth.orgId !== input.organizationId)
) {
// Hide existence on env mismatch: return not_found, same shape as
// a true miss, rather than 403 which would leak that the runId
// exists in some other env.
return { kind: "not_found" };
}
// Path 2 — buffer snapshot mutation.
const result: MutateSnapshotResult = await buffer.mutateSnapshot(input.runId, input.bufferPatch);
if (result === "applied_to_snapshot") {
return {
kind: "snapshot",
response: await input.synthesisedResponse({ bufferEntry: entryForAuth }),
};
}
if (result === "limit_exceeded") {
// The buffer refused the patch (e.g. tag cap). Nothing was written.
// Surface the caller's rejection body; a missing builder means the
// caller sent a rejectable patch without handling the rejection.
if (!input.rejectedResponse) {
throw new Error(
"mutateWithFallback: buffer returned 'limit_exceeded' but no rejectedResponse was provided"
);
}
return {
kind: "rejected",
response: await input.rejectedResponse({ bufferEntry: entryForAuth }),
};
}
if (result === "not_found") {
// Disambiguate a genuine 404 from a replica-lag miss: ask the writer
// directly. If the row just appeared post-drain we route through the
// PG mutation path.
const writerRow = await findRunInPg(writer, input.runId, input.environmentId);
if (writerRow) {
const response = await input.pgMutation(writerRow);
return { kind: "pg", response };
}
return { kind: "not_found" };
}
// result === "busy" — the entry is mid-handoff (DRAINING) or already
// materialised. We do NOT poll the primary for the row to appear: that
// piles read load onto the writer at exactly the moment mollifier exists
// to shed it. Instead we watch the buffer entry itself (cheap Redis
// reads). The drainer writes the PG row BEFORE it acks (sets
// `materialised`) or fails (deletes the entry), so the entry's own state
// is an authoritative, already-in-Redis signal for "is the row in PG
// yet?". Only once it resolves do we touch the primary — exactly once,
// for the real mutation.
const safetyNetMs = input.safetyNetMs ?? DEFAULT_SAFETY_NET_MS;
const maxPollStepMs = input.maxPollStepMs ?? DEFAULT_MAX_POLL_STEP_MS;
const backoffFactor = input.backoffFactor ?? DEFAULT_BACKOFF_FACTOR;
const random = input.random ?? Math.random;
const deadline = now() + safetyNetMs;
let step = input.pollStepMs ?? DEFAULT_POLL_STEP_MS;
while (now() < deadline) {
if (input.abortSignal?.aborted) {
return { kind: "timed_out" };
}
const entry = await buffer.getEntry(input.runId);
// Resolved when the entry is gone (`fail` deleted it after writing a
// terminal SYSTEM_FAILURE row) or materialised (`ack` after a
// successful trigger / cancel write). In both cases the PG row is now
// committed on the primary, so read it once and route through the
// canonical PG mutation path.
if (entry === null || entry.materialised === true) {
const row = await findRunInPg(writer, input.runId, input.environmentId);
if (row) {
const response = await input.pgMutation(row);
return { kind: "pg", response };
}
// Entry gone with no PG row: the drainer's terminal write itself
// failed (PG unreachable). Nothing to mutate.
return { kind: "not_found" };
}
// Still QUEUED (requeued after a retryable drain error) or DRAINING —
// the run hasn't reached PG. Back off with jitter so concurrent
// waiters on the same draining run don't requery in lockstep.
if (now() >= deadline) break;
const jittered = step + Math.floor(random() * step);
await sleep(jittered);
step = Math.min(Math.ceil(step * backoffFactor), maxPollStepMs);
}
logger.warn("mollifier mutate-with-fallback: drainer resolution timed out", {
runId: input.runId,
safetyNetMs,
});
return { kind: "timed_out" };
}
async function findRunInPg(
client: PrismaClientOrTransaction | PrismaReplicaClient,
friendlyId: string,
environmentId: string
): Promise<TaskRun | null> {
return runStore.findRun({ friendlyId, runtimeEnvironmentId: environmentId }, client);
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}