230 lines
9.8 KiB
TypeScript
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));
|
|
}
|