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

207 lines
9.6 KiB
TypeScript

import { applyMetadataOperations } from "@trigger.dev/core/v3";
import type { FlushedRunMetadata } from "@trigger.dev/core/v3/schemas";
import { RunId } from "@trigger.dev/core/v3/isomorphic";
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
import { logger } from "~/services/logger.server";
import { getMollifierBuffer } from "./mollifierBuffer.server";
// On `applied` we surface the parent/root friendlyIds captured during
// the snapshot read. Callers that fan parent/root metadata operations
// out to their respective runs can use these without a second
// `findRunByIdWithMollifierFallback` round trip — and, more importantly,
// without racing the drainer's terminal-failure path (which atomically
// DELetes the entry hash). Without these on the outcome the second
// read can come back null mid-route, silently dropping the caller's
// parentOperations / rootOperations after the primary mutation already
// landed on the snapshot.
//
// FriendlyIds (not internal cuids) because the consuming
// `routeOperationsToRun` helper gates on the `run_…` prefix to decide
// whether to attempt the buffer fallback; cuids would skip that path.
// The snapshot's `parentTaskRunId` / `rootTaskRunId` are engine-side
// cuids, so we convert via `RunId.toFriendlyId` here — identical to
// what `readFallback.server.ts` does when assembling its SyntheticRun.
export type ApplyMetadataMutationOutcome =
| {
kind: "applied";
newMetadata: Record<string, unknown>;
parentTaskRunFriendlyId: string | undefined;
rootTaskRunFriendlyId: string | undefined;
}
| { kind: "not_found" }
| { kind: "busy" }
| { kind: "version_exhausted" }
// Mirrors the PG-side `MetadataTooLargeError` (status 413). Carries
// the limit + observed size so the route can produce a useful body.
| { kind: "metadata_too_large"; maximumSize: number; observedSize: number };
// Apply a metadata PUT (body.metadata replace AND/OR body.operations
// deltas) to a buffered run's snapshot. Mirrors the PG-side
// `UpdateMetadataService.#updateRunMetadataWithOperations` retry loop:
// read snapshot → apply operations in JS → CAS-write back with the
// observed `metadataVersion`. Retries on conflict; bounded by
// `maxRetries`. The Lua CAS is the atomicity primitive — concurrent
// callers never lose an increment / append / set.
export async function applyMetadataMutationToBufferedRun(input: {
runId: string;
// Env+org scoping closes a cross-environment write gap on the buffer
// path: the route's PG path is already env-scoped via Prisma filters,
// and this helper now enforces the same isolation before any buffer
// write so a caller authed in env A can't mutate a buffered run that
// belongs to env B.
environmentId: string;
organizationId: string;
// Byte-size cap on the resulting metadata payload, mirroring the
// PG-side `UpdateMetadataService.maximumSize` (sourced from
// `env.TASK_RUN_METADATA_MAXIMUM_SIZE`). Required so the buffer path
// doesn't silently allow writes the PG path would have rejected.
maximumSize: number;
body: Pick<FlushedRunMetadata, "metadata" | "operations">;
buffer?: MollifierBuffer | null;
maxRetries?: number;
// Jittered conflict-backoff envelope: random in [0, base + attempt * step) ms.
backoffBaseMs?: number;
backoffStepMs?: number;
}): Promise<ApplyMetadataMutationOutcome> {
const buffer = input.buffer ?? getMollifierBuffer();
if (!buffer) return { kind: "not_found" };
// Default retry budget tuned for buffered-window concurrency. The
// PG-side `UpdateMetadataService` uses 3, which is fine when the only
// writer is the executing task itself. For a buffered run the writers
// are external API callers, and N parallel writers exhaust 3 retries
// quickly under contention. Bumping to 12 covers ~50-way concurrency
// with sub-percent failure probability; the cost is bounded (each
// retry is one Redis Lua call ~1ms).
const maxRetries = input.maxRetries ?? 12;
const backoffBaseMs = input.backoffBaseMs ?? 5;
const backoffStepMs = input.backoffStepMs ?? 5;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
const entry = await buffer.getEntry(input.runId);
if (!entry) return { kind: "not_found" };
// Env+org check: an entry from a different env is treated as a
// miss (not 403) so existence in other envs doesn't leak.
if (entry.envId !== input.environmentId || entry.orgId !== input.organizationId) {
return { kind: "not_found" };
}
if (entry.status !== "QUEUED" || entry.materialised) {
return { kind: "busy" };
}
const snapshot = JSON.parse(entry.payload) as Record<string, unknown>;
const currentMetadataType =
typeof snapshot.metadataType === "string" ? snapshot.metadataType : "application/json";
// Capture parent/root ids during this read so the caller can fan
// parent/root operations out without a second buffer.getEntry. If
// the drainer's terminal-failure path runs between our CAS-write
// below and the route's follow-up, the entry hash would be DELd
// and a second read would return null — silently dropping the
// caller's `body.parentOperations` / `body.rootOperations`. The ids
// themselves are immutable for a run, so capturing them on any
// loop iteration is fine.
const snapshotParentTaskRunInternalId =
typeof snapshot.parentTaskRunId === "string" ? snapshot.parentTaskRunId : undefined;
const snapshotParentTaskRunFriendlyId = snapshotParentTaskRunInternalId
? RunId.toFriendlyId(snapshotParentTaskRunInternalId)
: undefined;
const snapshotRootTaskRunInternalId =
typeof snapshot.rootTaskRunId === "string" ? snapshot.rootTaskRunId : undefined;
const snapshotRootTaskRunFriendlyId = snapshotRootTaskRunInternalId
? RunId.toFriendlyId(snapshotRootTaskRunInternalId)
: undefined;
// Match PG semantics: `body.operations` and `body.metadata` are
// mutually exclusive on a single request. The PG service
// (`UpdateMetadataService.#updateRunMetadata`) branches on
// `Array.isArray(body.operations)` — if operations are present it
// applies them on top of the EXISTING metadata and ignores
// `body.metadata` entirely; otherwise `body.metadata` is the new
// full value. Doing both here would make a request like
// `{ metadata: {b:2}, operations: [set c=3] }` produce
// `{b:2,c:3}` on the buffer vs `{a:1,c:3}` on PG, which silently
// changes semantics across the buffered/materialised boundary.
const parseSnapshotMetadata = (): Record<string, unknown> => {
if (typeof snapshot.metadata !== "string") return {};
try {
return JSON.parse(snapshot.metadata) as Record<string, unknown>;
} catch {
return {};
}
};
let metadataObject: Record<string, unknown>;
// Use `Array.isArray` (the PG service's predicate) instead of a
// truthy length check. For `{ metadata, operations: [] }` PG sees
// Array.isArray([])=true and no-ops on existing metadata; a
// `.length` check would treat the empty array as falsy and fall
// through to the `body.metadata` branch, replacing metadata —
// exactly the cross-boundary drift the comment above warns
// against.
if (Array.isArray(input.body.operations)) {
// Operations take precedence: apply on top of existing snapshot
// metadata; ignore `body.metadata` to match PG behaviour.
metadataObject = applyMetadataOperations(
parseSnapshotMetadata(),
input.body.operations
).newMetadata;
} else if (input.body.metadata !== undefined) {
// No operations — full replace.
metadataObject = input.body.metadata as Record<string, unknown>;
} else {
// Neither — write back existing snapshot metadata (no-op shape).
metadataObject = parseSnapshotMetadata();
}
const newMetadataStr = JSON.stringify(metadataObject);
// Size cap — match PG (`handleMetadataPacket` throws
// `MetadataTooLargeError` (413) when the JSON-encoded packet
// exceeds the configured cap). Reject in-loop, before CAS, so a
// single oversize write doesn't churn the retry budget.
const observedSize = Buffer.byteLength(newMetadataStr, "utf8");
if (observedSize > input.maximumSize) {
return {
kind: "metadata_too_large",
maximumSize: input.maximumSize,
observedSize,
};
}
const cas = await buffer.casSetMetadata({
runId: input.runId,
expectedVersion: entry.metadataVersion,
newMetadata: newMetadataStr,
newMetadataType: currentMetadataType,
});
if (cas.kind === "applied") {
return {
kind: "applied",
newMetadata: metadataObject,
parentTaskRunFriendlyId: snapshotParentTaskRunFriendlyId,
rootTaskRunFriendlyId: snapshotRootTaskRunFriendlyId,
};
}
if (cas.kind === "not_found") return { kind: "not_found" };
if (cas.kind === "busy") return { kind: "busy" };
// version_conflict — another caller wrote between our read + CAS.
// Small jittered backoff so a thundering herd of N retriers doesn't
// all re-read + re-CAS at exactly the same moment.
logger.debug("applyMetadataMutationToBufferedRun: version_conflict, retrying", {
runId: input.runId,
attempt,
observedVersion: entry.metadataVersion,
currentVersion: cas.currentVersion,
});
const backoffMs = Math.floor(Math.random() * (backoffBaseMs + attempt * backoffStepMs));
await new Promise((resolve) => setTimeout(resolve, backoffMs));
}
logger.warn("applyMetadataMutationToBufferedRun: retries exhausted", {
runId: input.runId,
maxRetries,
});
return { kind: "version_exhausted" };
}