chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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" };
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { TriggerTaskRequestBody } from "@trigger.dev/core/v3";
|
||||
import type { TriggerTaskServiceOptions } from "~/v3/services/triggerTask.server";
|
||||
|
||||
// Canonical payload shape written to the mollifier buffer when the gate
|
||||
// decides to mollify a trigger. At this stage the call site ALSO calls
|
||||
// engine.trigger directly (dual-write), so this is currently an
|
||||
// audit/preview record. A later change makes the buffer the primary write
|
||||
// path: the drainer's handler reads this payload and replays it through
|
||||
// engine.trigger to create the run in Postgres, and read-fallback
|
||||
// endpoints synthesise a Run view from it while it is still QUEUED.
|
||||
//
|
||||
// CONTRACT: this shape must contain everything the drainer-replay needs to
|
||||
// reconstruct an equivalent engine.trigger call. Today it is emitted to
|
||||
// logs; later it is serialised into Redis and rebuilt on the drain side.
|
||||
// Keep it serialisable — no functions, no class instances.
|
||||
export type BufferedTriggerPayload = {
|
||||
runFriendlyId: string;
|
||||
|
||||
// Routing identifiers — let the drainer re-fetch full AuthenticatedEnvironment
|
||||
// at replay time rather than embedding it in the payload.
|
||||
envId: string;
|
||||
envType: string;
|
||||
envSlug: string;
|
||||
orgId: string;
|
||||
orgSlug: string;
|
||||
projectId: string;
|
||||
projectRef: string;
|
||||
|
||||
// Task identifier — looked up against the locked BackgroundWorkerTask
|
||||
// at replay time to recover task-defaults.
|
||||
taskId: string;
|
||||
|
||||
// Customer-supplied trigger body (payload, options, context).
|
||||
body: TriggerTaskRequestBody;
|
||||
|
||||
// Resolved values from upstream concerns. The drainer should NOT re-resolve
|
||||
// these — that would create a second idempotency-key check, etc.
|
||||
idempotencyKey: string | null;
|
||||
idempotencyKeyExpiresAt: string | null;
|
||||
tags: string[];
|
||||
|
||||
// Parent/root linkage for nested triggers.
|
||||
parentRunFriendlyId: string | null;
|
||||
|
||||
// Trace context — propagates the original triggering span across the
|
||||
// buffer→drain boundary so the run's lifecycle stays under one trace.
|
||||
traceContext: Record<string, unknown>;
|
||||
|
||||
// Annotations + service options that influence routing/replay.
|
||||
triggerSource: string;
|
||||
triggerAction: string;
|
||||
serviceOptions: TriggerTaskServiceOptions;
|
||||
|
||||
// Wall-clock instants relevant to the run.
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
// Assemble the canonical payload from the inputs available at the point
|
||||
// `evaluateGate` returns "mollify" in `RunEngineTriggerTaskService.call`.
|
||||
// All fields must be derivable from data already in scope at that call site;
|
||||
// nothing should require an extra DB lookup.
|
||||
export function buildBufferedTriggerPayload(input: {
|
||||
runFriendlyId: string;
|
||||
taskId: string;
|
||||
envId: string;
|
||||
envType: string;
|
||||
envSlug: string;
|
||||
orgId: string;
|
||||
orgSlug: string;
|
||||
projectId: string;
|
||||
projectRef: string;
|
||||
body: TriggerTaskRequestBody;
|
||||
idempotencyKey: string | null;
|
||||
idempotencyKeyExpiresAt: Date | null;
|
||||
tags: string[];
|
||||
parentRunFriendlyId: string | null;
|
||||
traceContext: Record<string, unknown>;
|
||||
triggerSource: string;
|
||||
triggerAction: string;
|
||||
serviceOptions: TriggerTaskServiceOptions;
|
||||
createdAt: Date;
|
||||
}): BufferedTriggerPayload {
|
||||
return {
|
||||
runFriendlyId: input.runFriendlyId,
|
||||
envId: input.envId,
|
||||
envType: input.envType,
|
||||
envSlug: input.envSlug,
|
||||
orgId: input.orgId,
|
||||
orgSlug: input.orgSlug,
|
||||
projectId: input.projectId,
|
||||
projectRef: input.projectRef,
|
||||
taskId: input.taskId,
|
||||
body: input.body,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
idempotencyKeyExpiresAt:
|
||||
input.idempotencyKey && input.idempotencyKeyExpiresAt
|
||||
? input.idempotencyKeyExpiresAt.toISOString()
|
||||
: null,
|
||||
tags: input.tags,
|
||||
parentRunFriendlyId: input.parentRunFriendlyId,
|
||||
traceContext: input.traceContext,
|
||||
triggerSource: input.triggerSource,
|
||||
triggerAction: input.triggerAction,
|
||||
serviceOptions: input.serviceOptions,
|
||||
createdAt: input.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
IdempotencyClaimResult,
|
||||
IdempotencyLookupInput,
|
||||
MollifierBuffer,
|
||||
} from "@trigger.dev/redis-worker";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getMollifierBuffer } from "./mollifierBuffer.server";
|
||||
|
||||
// Tunables. The TTL on the claim key is bounded by typical trigger-pipeline
|
||||
// dwell; long enough that a slow PG insert doesn't expire mid-flight,
|
||||
// short enough that a crashed claimant unblocks waiters quickly.
|
||||
export const DEFAULT_CLAIM_TTL_SECONDS = 30;
|
||||
// safetyNetMs caps how long a waiter blocks before returning timed_out.
|
||||
// Matches the mutateWithFallback safety net so SDK retry policies don't
|
||||
// have to special-case this path.
|
||||
export const DEFAULT_CLAIM_WAIT_MS = 5_000;
|
||||
export const DEFAULT_CLAIM_POLL_MS = 25;
|
||||
|
||||
export type ClaimOrAwaitOutcome =
|
||||
// We own the claim. `token` MUST be passed to publishClaim/releaseClaim
|
||||
// so the buffer can compare-and-act against our ownership marker — a
|
||||
// late release from a previous claimant whose TTL expired cannot
|
||||
// erase our slot.
|
||||
| { kind: "claimed"; token: string }
|
||||
| { kind: "resolved"; runId: string } // someone else's runId; caller returns isCached:true
|
||||
| { kind: "timed_out" };
|
||||
|
||||
export type ClaimOrAwaitInput = IdempotencyLookupInput & {
|
||||
ttlSeconds?: number;
|
||||
safetyNetMs?: number;
|
||||
pollStepMs?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
// Test injection.
|
||||
buffer?: MollifierBuffer | null;
|
||||
now?: () => number;
|
||||
sleep?: (ms: number) => Promise<void>;
|
||||
// Test override for the ownership-token generator. Defaults to
|
||||
// `crypto.randomUUID()`. Tests pass a deterministic value so they
|
||||
// can assert publish/release pass-through.
|
||||
generateToken?: () => string;
|
||||
};
|
||||
|
||||
// Pre-gate Redis claim. All same-key triggers serialise through here
|
||||
// before the trigger pipeline runs. Returning `resolved` short-circuits
|
||||
// the trigger entirely — the caller responds with the cached runId.
|
||||
// Returning `claimed` means we own the claim and MUST publish the
|
||||
// winning runId on success (`publishClaim`) or release the claim on
|
||||
// failure (`releaseClaim`).
|
||||
//
|
||||
// Failure modes:
|
||||
// - Redis down at claim time: returns `claimed` (fail open, no
|
||||
// coordination). Customer is no worse than today's race; the
|
||||
// PG unique constraint is the eventual arbiter.
|
||||
// - Claimant crashes mid-pipeline: claim TTL expires, waiters
|
||||
// eventually time out, SDK retries.
|
||||
// - PG/buffer publish failure: waiters time out and SDK retries; next
|
||||
// attempt sees the eventual PG/buffer state via existing
|
||||
// IdempotencyKeyConcern PG-first lookup.
|
||||
export async function claimOrAwait(input: ClaimOrAwaitInput): Promise<ClaimOrAwaitOutcome> {
|
||||
const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
|
||||
if (!buffer) {
|
||||
// Mollifier disabled / buffer construction failed. Fall open —
|
||||
// caller proceeds with the trigger pipeline (PG unique constraint
|
||||
// backstop). The token is never read in this case (publish/release
|
||||
// are buffer-null no-ops downstream), so we skip the default
|
||||
// `randomUUID()` to keep the mollifier-OFF hot path allocation-free
|
||||
// for idempotency-keyed triggers — `triggerTask` is the
|
||||
// highest-throughput code path in the system. A test-injected
|
||||
// generator is still honoured for deterministic assertions.
|
||||
return { kind: "claimed", token: input.generateToken ? input.generateToken() : "" };
|
||||
}
|
||||
const generateToken = input.generateToken ?? randomUUID;
|
||||
// Generate the ownership token up front so the retry loop reuses it
|
||||
// — we're the same logical claimant across attempts; only the slot
|
||||
// owner changes between releases.
|
||||
const token = generateToken();
|
||||
const ttlSeconds = input.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
|
||||
const safetyNetMs = input.safetyNetMs ?? DEFAULT_CLAIM_WAIT_MS;
|
||||
const pollStepMs = input.pollStepMs ?? DEFAULT_CLAIM_POLL_MS;
|
||||
const now = input.now ?? Date.now;
|
||||
const sleep = input.sleep ?? defaultSleep;
|
||||
|
||||
const lookupInput: IdempotencyLookupInput = {
|
||||
envId: input.envId,
|
||||
taskIdentifier: input.taskIdentifier,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
};
|
||||
|
||||
// Initial claim attempt. Most production-path calls resolve here on
|
||||
// the first call (either we win, or the key is already resolved from
|
||||
// a prior burst).
|
||||
let result: IdempotencyClaimResult;
|
||||
try {
|
||||
result = await buffer.claimIdempotency({ ...lookupInput, token, ttlSeconds });
|
||||
} catch (err) {
|
||||
logger.warn("idempotency claim failed (fail-open)", {
|
||||
envId: input.envId,
|
||||
taskIdentifier: input.taskIdentifier,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { kind: "claimed", token };
|
||||
}
|
||||
|
||||
if (result.kind === "claimed") return { kind: "claimed", token };
|
||||
if (result.kind === "resolved") return result;
|
||||
|
||||
// result.kind === "pending" — wait/poll loop. May see the value flip
|
||||
// to "resolved" (winner published), the key vanish (winner released
|
||||
// on error → retry claim), or stay "pending" until the safety net.
|
||||
const deadline = now() + safetyNetMs;
|
||||
while (now() < deadline) {
|
||||
if (input.abortSignal?.aborted) return { kind: "timed_out" };
|
||||
await sleep(pollStepMs);
|
||||
|
||||
let current: IdempotencyClaimResult | null;
|
||||
try {
|
||||
current = await buffer.readClaim(lookupInput);
|
||||
} catch (err) {
|
||||
// Transient read failure — keep polling until deadline.
|
||||
logger.warn("idempotency claim read failed mid-poll", {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (current === null) {
|
||||
// Claimant released on error. Re-attempt the claim — one of the
|
||||
// waiters will win, the rest see "pending" again. Reuse our token:
|
||||
// we're still the same logical claimant, just contending for a
|
||||
// freshly empty slot.
|
||||
try {
|
||||
const retry = await buffer.claimIdempotency({ ...lookupInput, token, ttlSeconds });
|
||||
if (retry.kind === "claimed") return { kind: "claimed", token };
|
||||
if (retry.kind === "resolved") return retry;
|
||||
// "pending" again → keep polling.
|
||||
} catch (err) {
|
||||
logger.warn("idempotency claim retry failed", {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { kind: "claimed", token };
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (current.kind === "resolved") return current;
|
||||
// current.kind === "pending" → keep polling.
|
||||
}
|
||||
return { kind: "timed_out" };
|
||||
}
|
||||
|
||||
// Publish the winning runId so waiters resolve. Best-effort: failure
|
||||
// here means waiters will time out and the SDK will retry, which will
|
||||
// then find the row via the existing IdempotencyKeyConcern PG-first
|
||||
// check.
|
||||
export async function publishClaim(input: {
|
||||
envId: string;
|
||||
taskIdentifier: string;
|
||||
idempotencyKey: string;
|
||||
// Ownership token from the `claimed` outcome. Buffer compare-and-sets
|
||||
// on this so a publish from a stale claimant (TTL expired, another
|
||||
// claimant moved in) is a no-op rather than overwriting their claim.
|
||||
token: string;
|
||||
runId: string;
|
||||
ttlSeconds?: number;
|
||||
buffer?: MollifierBuffer | null;
|
||||
}): Promise<void> {
|
||||
const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
|
||||
if (!buffer) return;
|
||||
const ttlSeconds = input.ttlSeconds ?? DEFAULT_CLAIM_TTL_SECONDS;
|
||||
try {
|
||||
await buffer.publishClaim({
|
||||
envId: input.envId,
|
||||
taskIdentifier: input.taskIdentifier,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
token: input.token,
|
||||
runId: input.runId,
|
||||
ttlSeconds,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("idempotency claim publish failed", {
|
||||
envId: input.envId,
|
||||
taskIdentifier: input.taskIdentifier,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Release on pipeline failure. Best-effort. If the DEL fails, the claim
|
||||
// TTL is the safety net — waiters time out, SDK retries.
|
||||
export async function releaseClaim(input: {
|
||||
envId: string;
|
||||
taskIdentifier: string;
|
||||
idempotencyKey: string;
|
||||
// Ownership token from the `claimed` outcome. Buffer compare-and-
|
||||
// deletes on this so a release from a stale claimant whose TTL
|
||||
// expired can't wipe a new owner's claim.
|
||||
token: string;
|
||||
buffer?: MollifierBuffer | null;
|
||||
}): Promise<void> {
|
||||
const buffer = input.buffer === undefined ? getMollifierBuffer() : input.buffer;
|
||||
if (!buffer) return;
|
||||
try {
|
||||
await buffer.releaseClaim({
|
||||
envId: input.envId,
|
||||
taskIdentifier: input.taskIdentifier,
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
token: input.token,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn("idempotency claim release failed", {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function defaultSleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MollifierBuffer } from "@trigger.dev/redis-worker";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
|
||||
// DI seam type for consumers (e.g. triggerTask.server.ts) that need a
|
||||
// nullable buffer accessor at construction time.
|
||||
export type MollifierGetBuffer = () => MollifierBuffer | null;
|
||||
|
||||
function initializeMollifierBuffer(): MollifierBuffer {
|
||||
logger.debug("Initializing mollifier buffer", {
|
||||
host: env.TRIGGER_MOLLIFIER_REDIS_HOST,
|
||||
});
|
||||
|
||||
return new MollifierBuffer({
|
||||
redisOptions: {
|
||||
keyPrefix: "",
|
||||
host: env.TRIGGER_MOLLIFIER_REDIS_HOST,
|
||||
port: env.TRIGGER_MOLLIFIER_REDIS_PORT,
|
||||
username: env.TRIGGER_MOLLIFIER_REDIS_USERNAME,
|
||||
password: env.TRIGGER_MOLLIFIER_REDIS_PASSWORD,
|
||||
enableAutoPipelining: true,
|
||||
...(env.TRIGGER_MOLLIFIER_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
},
|
||||
ackGraceTtlSeconds: env.TRIGGER_MOLLIFIER_ACK_GRACE_TTL_SECONDS,
|
||||
maxRetriesPerRequest: env.TRIGGER_MOLLIFIER_REDIS_MAX_RETRIES_PER_REQUEST,
|
||||
reconnectStepMs: env.TRIGGER_MOLLIFIER_REDIS_RECONNECT_STEP_MS,
|
||||
reconnectMaxMs: env.TRIGGER_MOLLIFIER_REDIS_RECONNECT_MAX_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function getMollifierBuffer(): MollifierBuffer | null {
|
||||
if (env.TRIGGER_MOLLIFIER_ENABLED !== "1") return null;
|
||||
return singleton("mollifierBuffer", initializeMollifierBuffer);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { MollifierDrainer } from "@trigger.dev/redis-worker";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { engine as runEngine } from "~/v3/runEngine.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { getMollifierBuffer } from "./mollifierBuffer.server";
|
||||
import {
|
||||
createDrainerHandler,
|
||||
createDrainerTerminalFailureHandler,
|
||||
isRetryablePgError,
|
||||
} from "./mollifierDrainerHandler.server";
|
||||
import type { MollifierSnapshot } from "./mollifierSnapshot.server";
|
||||
|
||||
// Distinct error class for the deterministic "fail loud at boot" throws
|
||||
// below. The bootstrap in `mollifierDrainerWorker.server.ts` catches
|
||||
// transient/init errors and logs them so an unrelated Redis blip doesn't
|
||||
// crash the webapp, but it RETHROWS this class — a misconfigured
|
||||
// shutdown timeout or missing buffer is a deploy-time mistake that
|
||||
// should fail health checks and roll back, not silently disable a
|
||||
// half-rolled-out feature.
|
||||
//
|
||||
// The `name` getter is set explicitly so cross-realm `instanceof` checks
|
||||
// (e.g. when Remix dev hot-reloads the module and the consumer keeps a
|
||||
// reference to the old class) can fall back to `error.name === ...` and
|
||||
// still recognise the marker.
|
||||
export class MollifierConfigurationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "MollifierConfigurationError";
|
||||
}
|
||||
}
|
||||
|
||||
function initializeMollifierDrainer(): MollifierDrainer<MollifierSnapshot> {
|
||||
const buffer = getMollifierBuffer();
|
||||
if (!buffer) {
|
||||
// Unreachable in normal config: getMollifierDrainer() gates on the
|
||||
// same env flag as getMollifierBuffer(). If we hit this, fail loud
|
||||
// — the operator has set TRIGGER_MOLLIFIER_ENABLED=1 on a worker pod but
|
||||
// the buffer can't initialise (e.g. TRIGGER_MOLLIFIER_REDIS_HOST resolves
|
||||
// to nothing). Crashing surfaces the misconfig immediately rather
|
||||
// than silently leaving entries un-drained.
|
||||
throw new MollifierConfigurationError(
|
||||
"MollifierDrainer initialised without a buffer — env vars inconsistent"
|
||||
);
|
||||
}
|
||||
|
||||
// Validate BEFORE start() so a misconfigured shutdown timeout fails
|
||||
// loud at module-load time and the singleton is never cached. If start()
|
||||
// ran first and the throw propagated out, the loop would already be
|
||||
// polling with no SIGTERM handler registered by the caller — exactly
|
||||
// the failure mode the validation is supposed to prevent.
|
||||
//
|
||||
// The SIGTERM handler in mollifierDrainerWorker.server.ts is sync fire-and-forget:
|
||||
// `drainer.stop({ timeoutMs })` returns a promise that keeps the event
|
||||
// loop alive, but in cluster mode the primary runs its own
|
||||
// GRACEFUL_SHUTDOWN_TIMEOUT and will call `process.exit(0)`
|
||||
// independently. If the drainer's deadline exceeds the primary's, the
|
||||
// drainer is cut off mid-wait — "log a warning on timeout" turns into
|
||||
// "hard exit with no log". 1s margin gives the primary room to finish
|
||||
// its own teardown after the drainer settles.
|
||||
const shutdownMarginMs = env.TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_MARGIN_MS;
|
||||
if (
|
||||
env.TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_TIMEOUT_MS >=
|
||||
env.GRACEFUL_SHUTDOWN_TIMEOUT - shutdownMarginMs
|
||||
) {
|
||||
throw new MollifierConfigurationError(
|
||||
`TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_TIMEOUT_MS (${env.TRIGGER_MOLLIFIER_DRAIN_SHUTDOWN_TIMEOUT_MS}) must be at least ${shutdownMarginMs}ms below GRACEFUL_SHUTDOWN_TIMEOUT (${env.GRACEFUL_SHUTDOWN_TIMEOUT}); otherwise the primary's hard exit shadows the drainer's deadline.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.debug("Initializing mollifier drainer", {
|
||||
concurrency: env.TRIGGER_MOLLIFIER_DRAIN_CONCURRENCY,
|
||||
maxAttempts: env.TRIGGER_MOLLIFIER_DRAIN_MAX_ATTEMPTS,
|
||||
drainBatchSize: env.TRIGGER_MOLLIFIER_DRAIN_BATCH_SIZE,
|
||||
});
|
||||
|
||||
const drainer = new MollifierDrainer<MollifierSnapshot>({
|
||||
buffer,
|
||||
handler: createDrainerHandler({ engine: runEngine, prisma }),
|
||||
onTerminalFailure: createDrainerTerminalFailureHandler({ engine: runEngine, prisma }),
|
||||
concurrency: env.TRIGGER_MOLLIFIER_DRAIN_CONCURRENCY,
|
||||
maxAttempts: env.TRIGGER_MOLLIFIER_DRAIN_MAX_ATTEMPTS,
|
||||
maxOrgsPerTick: env.TRIGGER_MOLLIFIER_DRAIN_MAX_ORGS_PER_TICK,
|
||||
drainBatchSize: env.TRIGGER_MOLLIFIER_DRAIN_BATCH_SIZE,
|
||||
pollIntervalMs: env.TRIGGER_MOLLIFIER_DRAIN_POLL_INTERVAL_MS,
|
||||
maxBackoffMs: env.TRIGGER_MOLLIFIER_DRAIN_MAX_BACKOFF_MS,
|
||||
backoffFloorMs: env.TRIGGER_MOLLIFIER_DRAIN_BACKOFF_FLOOR_MS,
|
||||
isRetryable: isRetryablePgError,
|
||||
});
|
||||
|
||||
return drainer;
|
||||
}
|
||||
|
||||
// Returns a configured-but-stopped drainer. Callers MUST register their
|
||||
// SIGTERM / SIGINT shutdown handlers before invoking `drainer.start()` —
|
||||
// see `apps/webapp/app/v3/mollifierDrainerWorker.server.ts`. Starting
|
||||
// inside the singleton factory would put the polling loop ahead of
|
||||
// handler registration, leaving a narrow window where a SIGTERM landing
|
||||
// between `start()` and `process.once("SIGTERM", ...)` would skip the
|
||||
// graceful stop. The split is intentional.
|
||||
export function getMollifierDrainer(): MollifierDrainer<MollifierSnapshot> | null {
|
||||
if (env.TRIGGER_MOLLIFIER_ENABLED !== "1") return null;
|
||||
return singleton("mollifierDrainer", initializeMollifierDrainer);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { context, trace, TraceFlags } from "@opentelemetry/api";
|
||||
import type { RunEngine } from "@internal/run-engine";
|
||||
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type {
|
||||
MollifierDrainerHandler,
|
||||
MollifierDrainerTerminalFailureHandler,
|
||||
} from "@trigger.dev/redis-worker";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { recordRunDebugLog } from "~/v3/eventRepository/index.server";
|
||||
import { PerformTaskRunAlertsService } from "~/v3/services/alerts/performTaskRunAlerts.server";
|
||||
import { startSpan } from "~/v3/tracing.server";
|
||||
import type { MollifierSnapshot } from "./mollifierSnapshot.server";
|
||||
|
||||
const tracer = trace.getTracer("mollifier-drainer");
|
||||
|
||||
export function isRetryablePgError(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message ?? "";
|
||||
// Prisma surfaces P1001 ("Can't reach database server") via two
|
||||
// different error classes — `PrismaClientKnownRequestError` exposes
|
||||
// it as `err.code`, `PrismaClientInitializationError` exposes it as
|
||||
// `err.errorCode`. Check both so reconnection-time errors retry
|
||||
// regardless of which class fires.
|
||||
const code = (err as { code?: string }).code;
|
||||
const errorCode = (err as { errorCode?: string }).errorCode;
|
||||
if (code === "P2024") return true;
|
||||
if (code === "P1001" || errorCode === "P1001") return true;
|
||||
if (msg.includes("Can't reach database server")) return true;
|
||||
if (msg.includes("Connection lost")) return true;
|
||||
if (msg.includes("ECONNRESET")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function createDrainerHandler(deps: {
|
||||
engine: RunEngine;
|
||||
prisma: PrismaClientOrTransaction;
|
||||
}): MollifierDrainerHandler<MollifierSnapshot> {
|
||||
return async (input) => {
|
||||
const dwellMs = Date.now() - input.createdAt.getTime();
|
||||
|
||||
// Re-attach to the trace started by the caller's mollifier.queued span
|
||||
// (its traceId + spanId were captured into the snapshot at buffer time).
|
||||
// Without this the drainer would emit mollifier.drained in a brand-new
|
||||
// trace and the engine.trigger instrumentation would inherit an empty
|
||||
// active context — leaving the run-detail page with only the root span.
|
||||
const snapshotTraceId =
|
||||
typeof input.payload.traceId === "string" ? input.payload.traceId : undefined;
|
||||
const snapshotSpanId =
|
||||
typeof input.payload.spanId === "string" ? input.payload.spanId : undefined;
|
||||
|
||||
const parentContext =
|
||||
snapshotTraceId && snapshotSpanId
|
||||
? trace.setSpanContext(context.active(), {
|
||||
traceId: snapshotTraceId,
|
||||
spanId: snapshotSpanId,
|
||||
traceFlags: TraceFlags.SAMPLED,
|
||||
isRemote: true,
|
||||
})
|
||||
: context.active();
|
||||
|
||||
// Cancel-wins-over-trigger. If a cancel API call landed on this
|
||||
// entry while it was QUEUED, the snapshot carries `cancelledAt` +
|
||||
// `cancelReason`. Skip the normal materialise path and write a
|
||||
// CANCELED PG row directly. The `runCancelled` bus emit is
|
||||
// suppressed here because a buffered-only run never had a primary
|
||||
// trace event written for it — the runCancelled handler's
|
||||
// `cancelRunEvent` lookup would fail and log noise per cancel.
|
||||
const cancelledAtStr =
|
||||
typeof input.payload.cancelledAt === "string" ? input.payload.cancelledAt : undefined;
|
||||
if (cancelledAtStr) {
|
||||
const cancelReason =
|
||||
typeof input.payload.cancelReason === "string"
|
||||
? input.payload.cancelReason
|
||||
: "Canceled by user";
|
||||
await context.with(parentContext, async () => {
|
||||
await startSpan(tracer, "mollifier.drained.cancelled", async (span) => {
|
||||
span.setAttribute("mollifier.drained", true);
|
||||
span.setAttribute("mollifier.dwell_ms", dwellMs);
|
||||
span.setAttribute("mollifier.attempts", input.attempts);
|
||||
span.setAttribute("mollifier.run_friendly_id", input.runId);
|
||||
span.setAttribute("mollifier.cancel_bifurcation", true);
|
||||
span.setAttribute("taskRunId", input.runId);
|
||||
try {
|
||||
await deps.engine.createCancelledRun(
|
||||
{
|
||||
snapshot: input.payload as any,
|
||||
cancelledAt: new Date(cancelledAtStr),
|
||||
cancelReason,
|
||||
emitRunCancelledEvent: false,
|
||||
},
|
||||
deps.prisma
|
||||
);
|
||||
} catch (err) {
|
||||
// createCancelledRun throws a conflict when the normal trigger
|
||||
// replay path won the race and already materialised a live
|
||||
// (non-CANCELED) row for this friendlyId. Its contract leaves
|
||||
// the resolution to us: honour the cancel by actually
|
||||
// cancelling the now-live run. Letting the conflict propagate
|
||||
// would instead reach the drainer's terminal-failure path
|
||||
// (isRetryablePgError() is false for it), buffer.fail() the
|
||||
// entry, and silently lose the cancellation while the run
|
||||
// keeps executing.
|
||||
const isConflict =
|
||||
err instanceof Error && err.message.startsWith("createCancelledRun conflict");
|
||||
if (!isConflict) {
|
||||
// Mirror the SYSTEM_FAILURE fallback the non-cancelled
|
||||
// trigger path uses below. Without this branch, a
|
||||
// non-retryable createCancelledRun failure rethrows, the
|
||||
// drainer's onTerminalFailure handler skips because it
|
||||
// gates on `cause === "max-attempts-exhausted"` (and the
|
||||
// outer drainer classifies non-retryable failures with
|
||||
// `cause: "non-retryable"`), and buffer.fail() deletes
|
||||
// the entry — leaving NO PG row. The cancellation
|
||||
// disappears silently from the customer's dashboard.
|
||||
// Writing a SYSTEM_FAILURE row gives the run a terminal,
|
||||
// visible state.
|
||||
if (isRetryablePgError(err)) {
|
||||
throw err;
|
||||
}
|
||||
span.setAttribute(
|
||||
"mollifier.cancel_terminal_failure_reason",
|
||||
err instanceof Error ? err.message : String(err)
|
||||
);
|
||||
try {
|
||||
const wrote = await writeMollifierTerminalFailureRow(deps, {
|
||||
friendlyId: input.runId,
|
||||
snapshot: input.payload as Record<string, unknown>,
|
||||
reason: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
if (wrote) return;
|
||||
} catch (writeErr) {
|
||||
if (isRetryablePgError(writeErr)) {
|
||||
span.setAttribute("mollifier.cancel_terminal_write_retryable", true);
|
||||
throw writeErr;
|
||||
}
|
||||
span.setAttribute(
|
||||
"mollifier.cancel_terminal_write_error",
|
||||
writeErr instanceof Error ? writeErr.message : String(writeErr)
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
span.setAttribute("mollifier.cancel_conflict", true);
|
||||
const friendlyId =
|
||||
typeof input.payload.friendlyId === "string" ? input.payload.friendlyId : input.runId;
|
||||
await deps.engine.cancelRun({
|
||||
runId: RunId.fromFriendlyId(friendlyId),
|
||||
completedAt: new Date(cancelledAtStr),
|
||||
reason: cancelReason,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await context.with(parentContext, async () => {
|
||||
await startSpan(tracer, "mollifier.drained", async (span) => {
|
||||
span.setAttribute("mollifier.drained", true);
|
||||
span.setAttribute("mollifier.dwell_ms", dwellMs);
|
||||
span.setAttribute("mollifier.attempts", input.attempts);
|
||||
span.setAttribute("mollifier.run_friendly_id", input.runId);
|
||||
span.setAttribute("taskRunId", input.runId);
|
||||
|
||||
let triggerSucceeded = false;
|
||||
try {
|
||||
await deps.engine.trigger(input.payload as any, deps.prisma);
|
||||
triggerSucceeded = true;
|
||||
} catch (err) {
|
||||
// The retryable-PG class re-throws so the drainer's outer
|
||||
// worker loop can `buffer.requeue` (handled in
|
||||
// `MollifierDrainer.drainOne`). For non-retryable failures we
|
||||
// write a terminal SYSTEM_FAILURE row to PG via the engine's
|
||||
// existing `createFailedTaskRun` (used by batch-trigger for
|
||||
// the same purpose) so the customer sees the run in their
|
||||
// dashboard / SDK instead of silently losing it when the
|
||||
// buffer entry TTLs out. If THAT insert also fails (PG truly
|
||||
// unreachable), rethrow so the drainer's outer catch falls
|
||||
// through to its existing `buffer.fail` terminal-marker path.
|
||||
if (isRetryablePgError(err)) {
|
||||
throw err;
|
||||
}
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
span.setAttribute("mollifier.terminal_failure_reason", reason);
|
||||
try {
|
||||
const wrote = await writeMollifierTerminalFailureRow(deps, {
|
||||
friendlyId: input.runId,
|
||||
snapshot: input.payload as Record<string, unknown>,
|
||||
reason,
|
||||
});
|
||||
if (!wrote) {
|
||||
// Snapshot too malformed to even construct a TaskRun row.
|
||||
// Drainer's outer catch will buffer.fail this entry.
|
||||
throw err;
|
||||
}
|
||||
} catch (writeErr) {
|
||||
// The terminal SYSTEM_FAILURE write itself failed. If it
|
||||
// failed because PG is transiently unreachable, rethrow the
|
||||
// *write* error so the drainer requeues — buffer.fail()ing on
|
||||
// the original non-retryable error would lose the run with no
|
||||
// PG row ever landing. Once PG recovers the requeued entry
|
||||
// writes its failure row and the customer sees it.
|
||||
if (isRetryablePgError(writeErr)) {
|
||||
span.setAttribute("mollifier.terminal_write_retryable", true);
|
||||
throw writeErr;
|
||||
}
|
||||
// PG reachable but the write was rejected for another reason
|
||||
// (genuinely bad snapshot). Rethrow the original trigger error
|
||||
// so the drainer falls back to buffer.fail.
|
||||
span.setAttribute(
|
||||
"mollifier.terminal_write_error",
|
||||
writeErr instanceof Error ? writeErr.message : String(writeErr)
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Admin-only audit trail emitted once engine.trigger has
|
||||
// landed a PG row. `recordRunDebugLog` flips this to the
|
||||
// admin-gated debug kind (TaskEventKind.LOG in the PG store /
|
||||
// DEBUG_EVENT in the ClickHouse store) which the trace view +
|
||||
// logs download already strip for non-admins
|
||||
// (`eventRepository.server.ts:108`,
|
||||
// `resources.runs.$runParam.logs.download.ts:118`).
|
||||
//
|
||||
// Placement: emit as a zero-duration marker AT materialisation
|
||||
// time, not as a back-dated bar spanning the buffered window.
|
||||
// `engine.trigger` rewrites the run's root span at
|
||||
// materialisation (it adopts the synth root via traceId/spanId
|
||||
// carryover but updates start_time to "now"), so the trace
|
||||
// renderer treats materialisation time as t=0. A back-dated
|
||||
// event with startTime = bufferedAt would land before that t=0
|
||||
// and get clipped from the tree. Same pattern as the
|
||||
// `[engine] QUEUED` markers. The window itself is preserved
|
||||
// in metadata so admins can read it off the span detail pane.
|
||||
//
|
||||
// Best-effort: `recordRunDebugLog` has its own try/catch and
|
||||
// returns a result, so it never throws into the materialisation
|
||||
// path. Failures are logged but not surfaced because the
|
||||
// customer-visible run has already landed.
|
||||
if (triggerSucceeded) {
|
||||
const debugResult = await recordRunDebugLog(
|
||||
RunId.fromFriendlyId(input.runId),
|
||||
`Mollifier buffered ${dwellMs}ms before materialising`,
|
||||
{
|
||||
attributes: {
|
||||
runId: input.runId,
|
||||
metadata: {
|
||||
"mollifier.bufferedAt": input.createdAt.toISOString(),
|
||||
"mollifier.materialisedAt": new Date().toISOString(),
|
||||
"mollifier.dwellMs": dwellMs,
|
||||
"mollifier.attempts": input.attempts,
|
||||
},
|
||||
},
|
||||
parentId: snapshotSpanId,
|
||||
}
|
||||
);
|
||||
if (!debugResult.success && debugResult.code !== "RUN_NOT_FOUND") {
|
||||
logger.warn("mollifier drainer: failed to record admin debug log", {
|
||||
runId: input.runId,
|
||||
code: debugResult.code,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// Shared SYSTEM_FAILURE construction used by both terminal paths:
|
||||
// - non-retryable failure inside the handler (above)
|
||||
// - retryable failure after maxAttempts inside the drainer's
|
||||
// `processEntry` (via `createDrainerTerminalFailureHandler`)
|
||||
//
|
||||
// Suppresses `runFailed` and enqueues the alert manually — the engine's
|
||||
// `runFailed` handler calls `completeFailedRunEvent`, which looks up
|
||||
// the run's primary span. Buffered-only runs never had a primary trace
|
||||
// event written (the mollifier gate intercepts BEFORE
|
||||
// `repository.traceEvent` runs), so the lookup always fails and the
|
||||
// handler logs a systematic `[runFailed] Failed to complete failed
|
||||
// run event` error per terminal failure. `TriggerFailedTaskService`
|
||||
// handles the identical situation the same way (see triggerFailedTask
|
||||
// .server.ts:212 and 324) — pass `emitRunFailedEvent: false` to the
|
||||
// engine and call `PerformTaskRunAlertsService.enqueue(...)` directly
|
||||
// so customers' ERROR channels still fire. Alert enqueue is
|
||||
// best-effort; an alert-side failure is logged but does not bubble up
|
||||
// (the SYSTEM_FAILURE row landing is the load-bearing customer-visible
|
||||
// outcome).
|
||||
//
|
||||
// Returns the new `TaskRun` on success or `null` when the snapshot was
|
||||
// so malformed it couldn't even produce an environment — caller decides
|
||||
// whether to escalate that to `buffer.fail` directly. Throws on any
|
||||
// other failure so the drainer's retryable/non-retryable disposition
|
||||
// logic can own the decision.
|
||||
async function writeMollifierTerminalFailureRow(
|
||||
deps: { engine: RunEngine; prisma: PrismaClientOrTransaction },
|
||||
args: { friendlyId: string; snapshot: Record<string, unknown>; reason: string }
|
||||
) {
|
||||
const { snapshot } = args;
|
||||
const env = snapshot.environment as
|
||||
| {
|
||||
id: string;
|
||||
type: any;
|
||||
project: { id: string };
|
||||
organization: { id: string };
|
||||
}
|
||||
| undefined;
|
||||
if (!env) return null;
|
||||
// Extract batch association from the snapshot if present. Without this
|
||||
// a SYSTEM_FAILURE row for a buffered batch child won't be linked to
|
||||
// its batch, and the batch parent's completion tracking can hang
|
||||
// indefinitely waiting on a child that landed but isn't visible to
|
||||
// the batch.
|
||||
const rawBatch = snapshot.batch;
|
||||
const batch =
|
||||
rawBatch &&
|
||||
typeof rawBatch === "object" &&
|
||||
"id" in rawBatch &&
|
||||
typeof (rawBatch as { id: unknown }).id === "string" &&
|
||||
"index" in rawBatch &&
|
||||
typeof (rawBatch as { index: unknown }).index === "number"
|
||||
? (rawBatch as { id: string; index: number })
|
||||
: undefined;
|
||||
const failedRun = await deps.engine.createFailedTaskRun({
|
||||
friendlyId: args.friendlyId,
|
||||
environment: env,
|
||||
taskIdentifier: String(snapshot.taskIdentifier ?? ""),
|
||||
payload: typeof snapshot.payload === "string" ? snapshot.payload : undefined,
|
||||
payloadType: typeof snapshot.payloadType === "string" ? snapshot.payloadType : undefined,
|
||||
error: {
|
||||
type: "STRING_ERROR",
|
||||
raw: `Mollifier drainer terminal failure: ${args.reason}`,
|
||||
},
|
||||
parentTaskRunId:
|
||||
typeof snapshot.parentTaskRunId === "string" ? snapshot.parentTaskRunId : undefined,
|
||||
rootTaskRunId: typeof snapshot.rootTaskRunId === "string" ? snapshot.rootTaskRunId : undefined,
|
||||
depth: typeof snapshot.depth === "number" ? snapshot.depth : 0,
|
||||
resumeParentOnCompletion: snapshot.resumeParentOnCompletion === true,
|
||||
batch,
|
||||
traceId: typeof snapshot.traceId === "string" ? snapshot.traceId : undefined,
|
||||
spanId: typeof snapshot.spanId === "string" ? snapshot.spanId : undefined,
|
||||
taskEventStore:
|
||||
typeof snapshot.taskEventStore === "string" ? snapshot.taskEventStore : undefined,
|
||||
queue: typeof snapshot.queue === "string" ? snapshot.queue : undefined,
|
||||
lockedQueueId: typeof snapshot.lockedQueueId === "string" ? snapshot.lockedQueueId : undefined,
|
||||
emitRunFailedEvent: false,
|
||||
});
|
||||
// Alerts side of `runFailed` — the engine emit was suppressed above
|
||||
// so we don't create an orphan trace event; enqueue the alert
|
||||
// directly so customers' ERROR channels still see the failure.
|
||||
// Best-effort, mirroring TriggerFailedTaskService.
|
||||
try {
|
||||
await PerformTaskRunAlertsService.enqueue(failedRun.id);
|
||||
} catch (alertsError) {
|
||||
logger.warn("writeMollifierTerminalFailureRow: alert enqueue failed", {
|
||||
friendlyId: args.friendlyId,
|
||||
error: alertsError instanceof Error ? alertsError.message : String(alertsError),
|
||||
});
|
||||
}
|
||||
return failedRun;
|
||||
}
|
||||
|
||||
// Drainer-side terminal-failure callback. Fires from
|
||||
// `MollifierDrainer.processEntry` BEFORE `buffer.fail()` on any path
|
||||
// where the in-handler write didn't already land — currently the
|
||||
// `cause: "max-attempts-exhausted"` case for retryable PG errors. Writes
|
||||
// the same SYSTEM_FAILURE row the non-retryable handler path writes
|
||||
// inline (via the shared `writeMollifierTerminalFailureRow` helper) so
|
||||
// the customer-visible behaviour is identical regardless of how the
|
||||
// failure was classified.
|
||||
//
|
||||
// Re-throws retryable PG errors so the drainer requeues — buffer.fail()ing
|
||||
// here would still lose the run if PG is genuinely unreachable. Throwing
|
||||
// anything else falls through to buffer.fail to avoid an infinite loop on
|
||||
// a genuinely bad snapshot (the drainer logs it).
|
||||
export function createDrainerTerminalFailureHandler(deps: {
|
||||
engine: RunEngine;
|
||||
prisma: PrismaClientOrTransaction;
|
||||
}): MollifierDrainerTerminalFailureHandler<MollifierSnapshot> {
|
||||
return async (input) => {
|
||||
// The handler's own non-retryable terminal path has already written
|
||||
// the SYSTEM_FAILURE row before it throws non-retryable. Only the
|
||||
// retryable-exhausted path reaches us with no row written yet — gate
|
||||
// on `cause` to avoid double-writing for non-retryable failures.
|
||||
if (input.cause !== "max-attempts-exhausted") return;
|
||||
await startSpan(tracer, "mollifier.drained.terminal_failure", async (span) => {
|
||||
span.setAttribute("mollifier.drained", false);
|
||||
span.setAttribute("mollifier.attempts", input.attempts);
|
||||
span.setAttribute("mollifier.run_friendly_id", input.runId);
|
||||
span.setAttribute("mollifier.terminal_failure_cause", input.cause);
|
||||
span.setAttribute("mollifier.terminal_failure_reason", input.error.message);
|
||||
span.setAttribute("taskRunId", input.runId);
|
||||
await writeMollifierTerminalFailureRow(deps, {
|
||||
friendlyId: input.runId,
|
||||
snapshot: input.payload as Record<string, unknown>,
|
||||
reason: input.error.message,
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getMollifierBuffer } from "./mollifierBuffer.server";
|
||||
import { reportDrainingCount } from "./mollifierTelemetry.server";
|
||||
|
||||
// How often we ZCARD the draining-tracker set. Each poll is a single
|
||||
// O(1) Redis call, so cadence is bounded by "how fresh do we want the
|
||||
// gauge?" rather than cost. 15s gives a tight-enough window to spot a
|
||||
// brief OOM-induced spike without burning RTTs, and lines up well with
|
||||
// typical Prometheus scrape intervals.
|
||||
const POLL_INTERVAL_MS = 15_000;
|
||||
|
||||
let intervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Polls `mollifier:draining` cardinality on an interval and feeds the
|
||||
// gauge in `mollifierTelemetry.server.ts`. Started from the drainer
|
||||
// worker bootstrap (alongside `drainer.start()`) so it runs on the same
|
||||
// pods that actually pop/ack entries — observability is colocated with
|
||||
// the lifecycle.
|
||||
//
|
||||
// Idempotent: a second call is a no-op (Remix dev hot-reload re-runs
|
||||
// the bootstrap; the existing interval keeps ticking).
|
||||
export function startMollifierDrainingGauge(
|
||||
opts: {
|
||||
intervalMs?: number;
|
||||
getBuffer?: typeof getMollifierBuffer;
|
||||
} = {}
|
||||
): void {
|
||||
if (intervalHandle !== null) return;
|
||||
|
||||
const intervalMs = opts.intervalMs ?? POLL_INTERVAL_MS;
|
||||
const getBuffer = opts.getBuffer ?? getMollifierBuffer;
|
||||
|
||||
// Fire one poll immediately so the gauge populates before the first
|
||||
// scrape rather than reading 0 for a full interval after boot.
|
||||
const tick = async () => {
|
||||
const buffer = getBuffer();
|
||||
if (!buffer) return;
|
||||
try {
|
||||
const count = await buffer.getDrainingCount();
|
||||
reportDrainingCount(count);
|
||||
} catch (err) {
|
||||
// Transient Redis blip — don't tank the loop, just leave the
|
||||
// gauge at its last-known value. A sustained Redis outage will
|
||||
// surface via the drainer's own alerts long before this gauge
|
||||
// staleness becomes a primary signal.
|
||||
logger.warn("Mollifier draining gauge poll failed; keeping previous value", { err });
|
||||
}
|
||||
};
|
||||
|
||||
void tick();
|
||||
// unref so the interval doesn't keep the process alive past
|
||||
// graceful shutdown — the gauge is best-effort, not a flush boundary.
|
||||
intervalHandle = setInterval(() => {
|
||||
void tick();
|
||||
}, intervalMs);
|
||||
intervalHandle.unref?.();
|
||||
}
|
||||
|
||||
// Test seam. Production code never calls this; lifecycle is implicitly
|
||||
// process-end.
|
||||
export function stopMollifierDrainingGauge(): void {
|
||||
if (intervalHandle === null) return;
|
||||
clearInterval(intervalHandle);
|
||||
intervalHandle = null;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FEATURE_FLAG, FeatureFlagCatalog } from "~/v3/featureFlags";
|
||||
import { getMollifierBuffer } from "./mollifierBuffer.server";
|
||||
import { createRealTripEvaluator } from "./mollifierTripEvaluator.server";
|
||||
import {
|
||||
recordDecision,
|
||||
type DecisionOutcome,
|
||||
type RecordDecisionOptions,
|
||||
} from "./mollifierTelemetry.server";
|
||||
|
||||
// `count` is the fleet-wide fixed-window counter for the env (INCR with a
|
||||
// PEXPIRE armed on the first tick of each window — see
|
||||
// `mollifierEvaluateTrip` in `packages/redis-worker/src/mollifier/buffer.ts`).
|
||||
// All webapp replicas pointing at the same Redis share the key
|
||||
// `mollifier:rate:${envId}`, so the threshold is the fleet-wide ceiling
|
||||
// rather than a per-instance one. At a window boundary an env can briefly
|
||||
// admit up to ~2x threshold across the fleet before tripping (fixed-window
|
||||
// not sliding-window). The tripped marker is refreshed on every overage
|
||||
// call, so a sustained burst holds the divert state until the rate falls
|
||||
// below threshold within a window.
|
||||
export type TripDecision =
|
||||
| { divert: false }
|
||||
| {
|
||||
divert: true;
|
||||
reason: "per_env_rate";
|
||||
count: number;
|
||||
threshold: number;
|
||||
windowMs: number;
|
||||
holdMs: number;
|
||||
};
|
||||
|
||||
export type GateOutcome =
|
||||
| { action: "pass_through" }
|
||||
| { action: "mollify"; decision: Extract<TripDecision, { divert: true }> }
|
||||
| { action: "shadow_log"; decision: Extract<TripDecision, { divert: true }> };
|
||||
|
||||
export type GateInputs = {
|
||||
envId: string;
|
||||
orgId: string;
|
||||
taskId: string;
|
||||
// Org-scoped flag overrides — taken from `Organization.featureFlags` on the
|
||||
// AuthenticatedEnvironment at the call site. The repo-wide `flag()` helper
|
||||
// queries the global `FeatureFlag` table; passing per-org overrides lets the
|
||||
// mollifier opt in a single org without touching the global row, matching
|
||||
// the pattern used by `canAccessAi`, `canAccessPrivateConnections`, and the
|
||||
// compute-template beta gate.
|
||||
orgFeatureFlags: Record<string, unknown> | null;
|
||||
// Trigger options that drive the debounce / OTU / triggerAndWait
|
||||
// bypasses. The mollify path can't
|
||||
// serialise stateful callbacks (debounce), can't safely break OTU's
|
||||
// synchronous-rejection contract, and shouldn't intercept single
|
||||
// triggerAndWait (batchTriggerAndWait still funnels through per item).
|
||||
options?: {
|
||||
debounce?: unknown;
|
||||
oneTimeUseToken?: string;
|
||||
parentTaskRunId?: string;
|
||||
resumeParentOnCompletion?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type TripEvaluator = (inputs: GateInputs) => Promise<TripDecision>;
|
||||
|
||||
// DI seam type for consumers (e.g. triggerTask.server.ts) that inject the
|
||||
// gate at construction time. Deliberately narrower than `evaluateGate`'s
|
||||
// real signature — no `deps` param — because consumers only call it with
|
||||
// inputs and rely on the module-level defaults.
|
||||
export type MollifierEvaluateGate = (inputs: GateInputs) => Promise<GateOutcome>;
|
||||
|
||||
export type GateDependencies = {
|
||||
isMollifierEnabled: () => boolean;
|
||||
isShadowModeOn: () => boolean;
|
||||
resolveOrgFlag: (inputs: GateInputs) => Promise<boolean>;
|
||||
evaluator: TripEvaluator;
|
||||
logShadow: (inputs: GateInputs, decision: Extract<TripDecision, { divert: true }>) => void;
|
||||
logMollified: (inputs: GateInputs, decision: Extract<TripDecision, { divert: true }>) => void;
|
||||
recordDecision: (outcome: DecisionOutcome, opts: RecordDecisionOptions) => void;
|
||||
};
|
||||
|
||||
// `options` is a thunk so env reads happen per-evaluation, not at module load.
|
||||
// Don't "simplify" to a plain object — dynamic config relies on the
|
||||
// gate observing whichever env values are live at trigger time.
|
||||
const defaultEvaluator = createRealTripEvaluator({
|
||||
getBuffer: () => getMollifierBuffer(),
|
||||
options: () => ({
|
||||
windowMs: env.TRIGGER_MOLLIFIER_TRIP_WINDOW_MS,
|
||||
threshold: env.TRIGGER_MOLLIFIER_TRIP_THRESHOLD,
|
||||
holdMs: env.TRIGGER_MOLLIFIER_HOLD_MS,
|
||||
}),
|
||||
});
|
||||
|
||||
function logDivertDecision(
|
||||
message: "mollifier.would_mollify" | "mollifier.mollified",
|
||||
inputs: GateInputs,
|
||||
decision: Extract<TripDecision, { divert: true }>
|
||||
): void {
|
||||
logger.debug(message, {
|
||||
envId: inputs.envId,
|
||||
orgId: inputs.orgId,
|
||||
taskId: inputs.taskId,
|
||||
reason: decision.reason,
|
||||
count: decision.count,
|
||||
threshold: decision.threshold,
|
||||
windowMs: decision.windowMs,
|
||||
holdMs: decision.holdMs,
|
||||
});
|
||||
}
|
||||
|
||||
// Resolve the per-org mollifier flag purely from the in-memory
|
||||
// `Organization.featureFlags` JSON. No DB query — `triggerTask` is the
|
||||
// trigger hot path and the webapp CLAUDE.md forbids adding Prisma calls
|
||||
// there. The fleet-wide kill switch lives in `TRIGGER_MOLLIFIER_ENABLED`; rollout
|
||||
// is per-org via the JSON, matching the pattern used by `canAccessAi`,
|
||||
// `hasComputeAccess`, etc. There is no global `FeatureFlag` table read
|
||||
// in this path by design.
|
||||
export function makeResolveMollifierFlag(): (inputs: GateInputs) => Promise<boolean> {
|
||||
return (inputs) => {
|
||||
const override = inputs.orgFeatureFlags?.[FEATURE_FLAG.mollifierEnabled];
|
||||
if (override !== undefined) {
|
||||
const parsed = FeatureFlagCatalog[FEATURE_FLAG.mollifierEnabled].safeParse(override);
|
||||
if (parsed.success) {
|
||||
return Promise.resolve(parsed.data);
|
||||
}
|
||||
}
|
||||
return Promise.resolve(false);
|
||||
};
|
||||
}
|
||||
|
||||
const resolveMollifierFlag = makeResolveMollifierFlag();
|
||||
|
||||
export const defaultGateDependencies: GateDependencies = {
|
||||
isMollifierEnabled: () => env.TRIGGER_MOLLIFIER_ENABLED === "1",
|
||||
isShadowModeOn: () => env.TRIGGER_MOLLIFIER_SHADOW_MODE === "1",
|
||||
resolveOrgFlag: resolveMollifierFlag,
|
||||
evaluator: defaultEvaluator,
|
||||
logShadow: (inputs, decision) => logDivertDecision("mollifier.would_mollify", inputs, decision),
|
||||
logMollified: (inputs, decision) => logDivertDecision("mollifier.mollified", inputs, decision),
|
||||
recordDecision,
|
||||
};
|
||||
|
||||
export async function evaluateGate(
|
||||
inputs: GateInputs,
|
||||
deps: Partial<GateDependencies> = {}
|
||||
): Promise<GateOutcome> {
|
||||
const d = { ...defaultGateDependencies, ...deps };
|
||||
|
||||
// Resolve the per-org flag up front so every decision below — including
|
||||
// the bypasses — can be labelled enrolled vs not on the
|
||||
// `mollifier.decisions` counter. Fail open: a transient error must not
|
||||
// block triggers. The resolver is purely in-memory (reads
|
||||
// `Organization.featureFlags`); it adds no DB round-trip to the hot path.
|
||||
let orgFlagEnabled: boolean;
|
||||
try {
|
||||
orgFlagEnabled = await d.resolveOrgFlag(inputs);
|
||||
} catch (error) {
|
||||
logger.warn("mollifier.resolve_org_flag_failed", {
|
||||
envId: inputs.envId,
|
||||
orgId: inputs.orgId,
|
||||
taskId: inputs.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
orgFlagEnabled = false;
|
||||
}
|
||||
// Passed to every `recordDecision`. `org` only becomes a label for the
|
||||
// (operationally capped) enrolled cohort — the guard is in
|
||||
// `decisionLabels`, so passing orgId unconditionally here is safe.
|
||||
const labels: RecordDecisionOptions = { enrolled: orgFlagEnabled, orgId: inputs.orgId };
|
||||
|
||||
// Debounce bypass. onDebounced is a closure over webapp state and
|
||||
// can't be snapshotted into the buffer for drainer replay. Skip before the
|
||||
// trip evaluator so debounce traffic is never counted against the rate.
|
||||
if (inputs.options?.debounce) {
|
||||
d.recordDecision("pass_through", labels);
|
||||
return { action: "pass_through" };
|
||||
}
|
||||
// OneTimeUseToken bypass. OTU is a security feature on the PUBLIC_JWT
|
||||
// auth path; its synchronous-rejection contract is materially worse to
|
||||
// break than the idempotency-key contract.
|
||||
if (inputs.options?.oneTimeUseToken) {
|
||||
d.recordDecision("pass_through", labels);
|
||||
return { action: "pass_through" };
|
||||
}
|
||||
// Single triggerAndWait bypass. batchTriggerAndWait still funnels
|
||||
// through TriggerTaskService.call per item so the dominant burst pattern
|
||||
// remains covered.
|
||||
if (inputs.options?.parentTaskRunId && inputs.options?.resumeParentOnCompletion) {
|
||||
d.recordDecision("pass_through", labels);
|
||||
return { action: "pass_through" };
|
||||
}
|
||||
|
||||
if (!d.isMollifierEnabled()) {
|
||||
d.recordDecision("pass_through", labels);
|
||||
return { action: "pass_through" };
|
||||
}
|
||||
|
||||
const shadowOn = d.isShadowModeOn();
|
||||
|
||||
if (!orgFlagEnabled && !shadowOn) {
|
||||
d.recordDecision("pass_through", labels);
|
||||
return { action: "pass_through" };
|
||||
}
|
||||
|
||||
// Fail open on evaluator errors too. The default `createRealTripEvaluator`
|
||||
// catches its own errors and returns `{ divert: false }`, but injected or
|
||||
// future evaluators may not — keep the contract symmetric with the org
|
||||
// flag resolution above so the trigger hot path can never be broken by a
|
||||
// gate-internal failure.
|
||||
//
|
||||
// Note: the evaluator INCRs the per-env Redis counter (`mollifier:rate:${envId}`)
|
||||
// in *both* shadow-only and flag-on modes — shadow mode is observation-only at
|
||||
// the user-visible level (no diversion), but not Redis-passive. It has to write
|
||||
// because the threshold is computed from a counter, and a counter that doesn't
|
||||
// increment isn't a counter. There's no cross-org bleed: `RuntimeEnvironment`
|
||||
// is 1:1 with `Organization`, so the per-env counter is effectively per-org.
|
||||
let decision: TripDecision;
|
||||
try {
|
||||
decision = await d.evaluator(inputs);
|
||||
} catch (error) {
|
||||
logger.warn("mollifier.evaluator_failed", {
|
||||
envId: inputs.envId,
|
||||
orgId: inputs.orgId,
|
||||
taskId: inputs.taskId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
decision = { divert: false };
|
||||
}
|
||||
if (!decision.divert) {
|
||||
d.recordDecision("pass_through", labels);
|
||||
return { action: "pass_through" };
|
||||
}
|
||||
|
||||
if (orgFlagEnabled) {
|
||||
d.logMollified(inputs, decision);
|
||||
d.recordDecision("mollify", { ...labels, reason: decision.reason });
|
||||
return { action: "mollify", decision };
|
||||
}
|
||||
|
||||
d.logShadow(inputs, decision);
|
||||
d.recordDecision("shadow_log", { ...labels, reason: decision.reason });
|
||||
return { action: "shadow_log", decision };
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { RunId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
||||
import { serialiseMollifierSnapshot, type MollifierSnapshot } from "./mollifierSnapshot.server";
|
||||
import type { TripDecision } from "./mollifierGate.server";
|
||||
|
||||
export type MollifyNotice = {
|
||||
code: "mollifier.queued";
|
||||
message: string;
|
||||
docs: string;
|
||||
};
|
||||
|
||||
export type MollifySyntheticResult = {
|
||||
// `id` is the canonical TaskRun primary key derived from `friendlyId`
|
||||
// via `RunId.fromFriendlyId`. Downstream consumers in the trigger
|
||||
// route — notably `saveRequestIdempotency` — index the request-
|
||||
// idempotency cache by this id; without it the cache stores
|
||||
// `undefined` and Prisma's `findFirst({ where: { id: undefined } })`
|
||||
// on retry strips the predicate and returns an arbitrary TaskRun
|
||||
// (potential cross-tenant leak). Always populated.
|
||||
//
|
||||
// `spanId` is the root-span id allocated at gate-accept time and
|
||||
// stored in the snapshot. Callers like the dashboard's Test action
|
||||
// use it to build a `v3RunSpanPath` URL that auto-opens the right
|
||||
// details panel — without it, the buffered run lands on the
|
||||
// run-detail page with no span selected (parity gap with PG runs).
|
||||
run: { id: string; friendlyId: string; spanId: string };
|
||||
error: undefined;
|
||||
// The race-loser path: if accept's SETNX hit an existing
|
||||
// buffered run with the same (env, task, idempotencyKey), the
|
||||
// response echoes the winner's runId with isCached=true. The
|
||||
// mollifier-queued notice is only attached for the happy accept.
|
||||
isCached: boolean;
|
||||
notice?: MollifyNotice;
|
||||
};
|
||||
|
||||
const NOTICE: MollifyNotice = {
|
||||
code: "mollifier.queued",
|
||||
message: "Trigger accepted into burst buffer. Consider batchTrigger for fan-outs of 100+.",
|
||||
docs: "https://trigger.dev/docs/management/tasks/batch-trigger",
|
||||
};
|
||||
|
||||
export async function mollifyTrigger(args: {
|
||||
runFriendlyId: string;
|
||||
environmentId: string;
|
||||
organizationId: string;
|
||||
engineTriggerInput: MollifierSnapshot;
|
||||
decision: Extract<TripDecision, { divert: true }>;
|
||||
buffer: MollifierBuffer;
|
||||
// Optional idempotency context. When both are passed, accept SETNXes
|
||||
// the lookup so the buffered window participates in trigger-time
|
||||
// dedup symmetrically with PG.
|
||||
idempotencyKey?: string;
|
||||
taskIdentifier?: string;
|
||||
}): Promise<MollifySyntheticResult> {
|
||||
const result = await args.buffer.accept({
|
||||
runId: args.runFriendlyId,
|
||||
envId: args.environmentId,
|
||||
orgId: args.organizationId,
|
||||
payload: serialiseMollifierSnapshot(args.engineTriggerInput),
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
taskIdentifier: args.taskIdentifier,
|
||||
});
|
||||
|
||||
if (result.kind === "duplicate_idempotency") {
|
||||
// Race loser. Echo the winner's runId so the SDK's response shape
|
||||
// matches PG-side idempotency cache hits. The winner's spanId isn't
|
||||
// readily available without a second buffer fetch; an empty string
|
||||
// causes `v3RunSpanPath` to omit the `?span=` param, which matches
|
||||
// current behaviour for cached PG responses.
|
||||
return {
|
||||
run: {
|
||||
id: RunId.fromFriendlyId(result.existingRunId),
|
||||
friendlyId: result.existingRunId,
|
||||
spanId: "",
|
||||
},
|
||||
error: undefined,
|
||||
isCached: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Both "accepted" and "duplicate_run_id" produce the same customer-
|
||||
// visible response: a buffered-trigger acknowledgement. The duplicate
|
||||
// runId case is unreachable in practice (runIds are server-generated
|
||||
// and unique) but is silently idempotent at the buffer layer either way.
|
||||
const rawSpanId = args.engineTriggerInput.spanId;
|
||||
const spanId = typeof rawSpanId === "string" ? rawSpanId : "";
|
||||
return {
|
||||
run: {
|
||||
id: RunId.fromFriendlyId(args.runFriendlyId),
|
||||
friendlyId: args.runFriendlyId,
|
||||
spanId,
|
||||
},
|
||||
error: undefined,
|
||||
isCached: false,
|
||||
notice: NOTICE,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { serialiseSnapshot, deserialiseSnapshot } from "@trigger.dev/redis-worker";
|
||||
|
||||
// MollifierSnapshot is the JSON-serialisable shape of the input that would be
|
||||
// passed to engine.trigger(). The drainer deserialises and replays it.
|
||||
// Kept as Record<string, unknown> at this layer — the engine.trigger call site
|
||||
// casts it to the engine's typed input. This keeps the mollifier subdirectory
|
||||
// from depending on @internal/run-engine internals.
|
||||
export type MollifierSnapshot = Record<string, unknown>;
|
||||
|
||||
export function serialiseMollifierSnapshot(input: MollifierSnapshot): string {
|
||||
return serialiseSnapshot(input);
|
||||
}
|
||||
|
||||
export function deserialiseMollifierSnapshot(serialised: string): MollifierSnapshot {
|
||||
return deserialiseSnapshot<MollifierSnapshot>(serialised);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
||||
import { logger as defaultLogger } from "~/services/logger.server";
|
||||
import { getMollifierBuffer } from "./mollifierBuffer.server";
|
||||
import { type StaleSweepStateStore } from "./mollifierStaleSweepState.server";
|
||||
import {
|
||||
recordStaleEntry as defaultRecordStaleEntry,
|
||||
reportStaleEntrySnapshot as defaultReportStaleEntrySnapshot,
|
||||
} from "./mollifierTelemetry.server";
|
||||
|
||||
// One pass of the sweep scans a bounded slice of orgs from the buffer's
|
||||
// queue LIST, identified by a durable cursor in Redis. Per-env entry
|
||||
// scan is also bounded so a single pathological env can't extend the
|
||||
// pass.
|
||||
const DEFAULT_MAX_ENTRIES_PER_ENV = 1000;
|
||||
// Max orgs visited per tick. Together with `maxEntriesPerEnv` this
|
||||
// caps Redis traffic per pass. One "cycle" (visiting every org once)
|
||||
// takes `ceil(N_orgs / cap)` ticks, after which the cursor wraps and a
|
||||
// fresh org list is taken.
|
||||
const DEFAULT_MAX_ORGS_PER_PASS = 100;
|
||||
|
||||
export type StaleSweepConfig = {
|
||||
// Entries whose dwell exceeds this threshold are flagged stale. Set
|
||||
// it well below `entryTtlSeconds * 1000` so ops have lead time before
|
||||
// TTL-induced silent loss; the default (half of entryTtlSeconds)
|
||||
// matches the cadence in the plan doc.
|
||||
staleThresholdMs: number;
|
||||
maxEntriesPerEnv?: number;
|
||||
// Hard cap on orgs visited per tick. Bounds the per-pass Redis traffic
|
||||
// and wall-time. Default 100 — at typical fleet sizes one or two
|
||||
// ticks cover everyone; under incident-scale fan-out a full cycle
|
||||
// takes a handful of ticks (~minutes) which is still well below the
|
||||
// staleness signal latency that ops cares about.
|
||||
maxOrgsPerPass?: number;
|
||||
};
|
||||
|
||||
export type StaleSweepDeps = {
|
||||
getBuffer?: () => MollifierBuffer | null;
|
||||
// Durable cursor + per-env counts hash. Required: the sweep is
|
||||
// useless without persistent state across ticks. The webapp wires up
|
||||
// a real `MollifierStaleSweepState`; tests pass one constructed
|
||||
// against the test container.
|
||||
state: StaleSweepStateStore;
|
||||
// No `envId` arg — `envId` is a high-cardinality metric attribute and
|
||||
// is intentionally not emitted as a metric label. The structured warn
|
||||
// log below carries envId for forensic drill-down.
|
||||
recordStaleEntry?: () => void;
|
||||
reportStaleEntrySnapshot?: (snapshot: Map<string, number>) => void;
|
||||
logger?: { warn: (message: string, fields: Record<string, unknown>) => void };
|
||||
now?: () => number;
|
||||
};
|
||||
|
||||
export type StaleSweepResult = {
|
||||
orgsScanned: number;
|
||||
envsScanned: number;
|
||||
entriesScanned: number;
|
||||
staleCount: number;
|
||||
};
|
||||
|
||||
// Walks a bounded slice of `orgs → envs → entries`, emitting an OTel
|
||||
// counter tick and a structured warning log for each buffer entry whose
|
||||
// dwell exceeds the stale threshold. Read-only on the buffer's own
|
||||
// state; writes only to the sweep's three dedicated keys
|
||||
// (`mollifier:stale_sweep:*`). The sweep does NOT remove or salvage
|
||||
// buffer entries; that decision is deferred to a separate retention-
|
||||
// policy change. The signal here exists so ops sees the drainer falling
|
||||
// behind well before TTL-induced loss kicks in.
|
||||
//
|
||||
// Sharding contract:
|
||||
// - Cursor starts at 0. On cursor=0 the org list is refreshed by
|
||||
// snapshotting `buffer.listOrgs()` into the durable LIST — that is
|
||||
// the cycle's frozen view of orgs to visit.
|
||||
// - Each tick consumes up to `maxOrgsPerPass` orgs from the LIST,
|
||||
// advances the cursor, and persists.
|
||||
// - When the cursor reaches the end of the LIST it wraps to 0; the next
|
||||
// tick rebuilds the org list, capturing any orgs that joined the
|
||||
// buffer mid-cycle.
|
||||
// - The per-env counts HASH carries over across ticks: an env visited
|
||||
// on tick N and not revisited until tick N+M keeps its last-known
|
||||
// stale count in the gauge for that window. This is the price of
|
||||
// sharding — accepted because the alternative (re-scan everything
|
||||
// every tick) does not bound work.
|
||||
export async function runStaleSweepOnce(
|
||||
config: StaleSweepConfig,
|
||||
deps: StaleSweepDeps
|
||||
): Promise<StaleSweepResult> {
|
||||
const getBuffer = deps.getBuffer ?? getMollifierBuffer;
|
||||
const recordStale = deps.recordStaleEntry ?? defaultRecordStaleEntry;
|
||||
const reportSnapshot = deps.reportStaleEntrySnapshot ?? defaultReportStaleEntrySnapshot;
|
||||
const log = deps.logger ?? defaultLogger;
|
||||
const now = (deps.now ?? Date.now)();
|
||||
const maxEntries = config.maxEntriesPerEnv ?? DEFAULT_MAX_ENTRIES_PER_ENV;
|
||||
const maxOrgsPerPass = config.maxOrgsPerPass ?? DEFAULT_MAX_ORGS_PER_PASS;
|
||||
|
||||
const buffer = getBuffer();
|
||||
if (!buffer) {
|
||||
// Replace any previous snapshot with empty so a previously-paging
|
||||
// env doesn't stay latched if mollifier is turned off mid-flight.
|
||||
// Also clear the durable state so a re-enable starts from a clean
|
||||
// slate instead of resuming on a stale cursor.
|
||||
await deps.state.clearAll();
|
||||
reportSnapshot(new Map());
|
||||
return { orgsScanned: 0, envsScanned: 0, entriesScanned: 0, staleCount: 0 };
|
||||
}
|
||||
|
||||
let cursor = await deps.state.readCursor();
|
||||
if (cursor === 0) {
|
||||
// Fresh cycle — capture the current set of orgs into the frozen
|
||||
// LIST. Any orgs that join after this snapshot wait until the next
|
||||
// cycle to be visited. Acceptable for an observational sweep; the
|
||||
// staleness signal would only fire on entries that have been
|
||||
// dwelling for `staleThresholdMs` anyway, so they're not new.
|
||||
const orgs = await buffer.listOrgs();
|
||||
await deps.state.rebuildOrgList(orgs);
|
||||
}
|
||||
|
||||
const { orgs: slice, total } = await deps.state.readOrgListSlice(cursor, maxOrgsPerPass);
|
||||
|
||||
let envsScanned = 0;
|
||||
let entriesScanned = 0;
|
||||
let staleCount = 0;
|
||||
|
||||
for (const orgId of slice) {
|
||||
const envs = await buffer.listEnvsForOrg(orgId);
|
||||
for (const envId of envs) {
|
||||
envsScanned += 1;
|
||||
let envStale = 0;
|
||||
const entries = await buffer.listEntriesForEnv(envId, maxEntries);
|
||||
for (const entry of entries) {
|
||||
entriesScanned += 1;
|
||||
const dwellMs = now - entry.createdAt.getTime();
|
||||
if (dwellMs > config.staleThresholdMs) {
|
||||
recordStale();
|
||||
log.warn("mollifier.stale_entry", {
|
||||
runId: entry.runId,
|
||||
envId,
|
||||
orgId,
|
||||
dwellMs,
|
||||
staleThresholdMs: config.staleThresholdMs,
|
||||
});
|
||||
envStale += 1;
|
||||
}
|
||||
}
|
||||
// Persist the per-env count to the durable hash. HSET when stale
|
||||
// > 0, HDEL when it dropped back to zero — the hash is the source
|
||||
// of truth for the gauge snapshot below.
|
||||
await deps.state.setEnvStaleCount(envId, envStale);
|
||||
// Track that this env was visited during the current cycle. The
|
||||
// reconcile step at cycle wrap uses this to HDEL counts hash
|
||||
// entries for envs that fully drained mid-cycle (they disappear
|
||||
// from listEnvsForOrg, so the inner loop above never reaches them
|
||||
// and never HDELs their hash field — without reconcile the gauge
|
||||
// would stay elevated forever).
|
||||
await deps.state.markEnvVisited(envId);
|
||||
staleCount += envStale;
|
||||
}
|
||||
}
|
||||
|
||||
// Advance the cursor. If the slice consumed the end of the LIST, wrap
|
||||
// to 0 so the next tick rebuilds the org list and starts a new cycle.
|
||||
const advanced = cursor + slice.length;
|
||||
const wrapped = advanced >= total;
|
||||
const newCursor = wrapped ? 0 : advanced;
|
||||
await deps.state.writeCursor(newCursor);
|
||||
|
||||
if (wrapped) {
|
||||
// Cycle ended. HDEL any env still in the counts hash that didn't
|
||||
// appear in any tick of the just-completed cycle — these are envs
|
||||
// that fully drained from the buffer mid-cycle and would otherwise
|
||||
// hold their stale gauge value forever. Also DELs the visited set
|
||||
// so the next cycle starts clean.
|
||||
await deps.state.reconcileVisited();
|
||||
}
|
||||
|
||||
// Emit the snapshot from the durable hash, which carries values for
|
||||
// envs visited in earlier ticks too. This is what makes the gauge
|
||||
// stable across ticks (and across webapp restarts).
|
||||
const snapshot = await deps.state.readAllEnvStaleCounts();
|
||||
reportSnapshot(snapshot);
|
||||
|
||||
return { orgsScanned: slice.length, envsScanned, entriesScanned, staleCount };
|
||||
}
|
||||
|
||||
export type StaleSweepIntervalHandle = {
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Production wrapper: schedule `runStaleSweepOnce` on a fixed interval.
|
||||
// One pass at a time — if a sweep is still running when the timer fires
|
||||
// the next tick is skipped (a backed-up Redis would otherwise queue
|
||||
// overlapping sweeps that all log the same stale entries).
|
||||
export function startStaleSweepInterval(
|
||||
config: StaleSweepConfig & { intervalMs: number },
|
||||
deps: StaleSweepDeps
|
||||
): StaleSweepIntervalHandle {
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
// Tracks the current tick so `stop()` can await it before closing the
|
||||
// state's Redis client. Without this, a tick that's already past the
|
||||
// `stopped` guard at entry would continue making `state.*` calls
|
||||
// against an ioredis client that `stop()` has already `quit()`ed,
|
||||
// raising errors that the tick's own try/catch then logs as
|
||||
// `mollifier.stale_sweep.failed` warnings — spurious noise on every
|
||||
// graceful shutdown.
|
||||
let currentTick: Promise<void> | null = null;
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped || inFlight) return;
|
||||
inFlight = true;
|
||||
const run = (async () => {
|
||||
try {
|
||||
await runStaleSweepOnce(config, deps);
|
||||
} catch (err) {
|
||||
const log = deps.logger ?? defaultLogger;
|
||||
log.warn("mollifier.stale_sweep.failed", {
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
} finally {
|
||||
inFlight = false;
|
||||
currentTick = null;
|
||||
}
|
||||
})();
|
||||
currentTick = run;
|
||||
await run;
|
||||
};
|
||||
|
||||
const timer = setInterval(() => {
|
||||
void tick();
|
||||
}, config.intervalMs);
|
||||
|
||||
return {
|
||||
stop: async () => {
|
||||
stopped = true;
|
||||
clearInterval(timer);
|
||||
// Drain any tick that started before `stopped` flipped. Its
|
||||
// `state.*` calls must land before we close the Redis client.
|
||||
if (currentTick) {
|
||||
try {
|
||||
await currentTick;
|
||||
} catch {
|
||||
// tick has its own catch — this await is just to ensure
|
||||
// ordering, not to surface errors that have already been
|
||||
// logged inside the tick.
|
||||
}
|
||||
}
|
||||
// Close the state's underlying resource. The `close()` method is
|
||||
// part of the `StaleSweepStateStore` contract — production's
|
||||
// `MollifierStaleSweepState` shuts down its ioredis client; fake
|
||||
// test states implement a no-op.
|
||||
await deps.state.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { createRedisClient, type Redis, type RedisOptions } from "@internal/redis";
|
||||
import { Logger } from "@trigger.dev/core/logger";
|
||||
|
||||
// Durable per-tick state for the sharded stale sweep. Four Redis keys,
|
||||
// all in the `mollifier:` namespace alongside the buffer's own state:
|
||||
//
|
||||
// mollifier:stale_sweep:cursor STRING next position in org_list (0 = fresh cycle)
|
||||
// mollifier:stale_sweep:org_list LIST org IDs frozen at the start of the cycle
|
||||
// mollifier:stale_sweep:counts HASH envId -> last-known stale count
|
||||
// mollifier:stale_sweep:visited SET envIds visited during the current cycle
|
||||
//
|
||||
// The state survives webapp restarts: a restarted process picks up the
|
||||
// cursor where the previous one left off and re-emits the last-known
|
||||
// gauge values immediately, rather than blinking to zero until the next
|
||||
// cycle visits each env.
|
||||
//
|
||||
// The `visited` set exists to GC the `counts` hash at cycle wrap: an env
|
||||
// that drains completely between sweep ticks disappears from
|
||||
// `buffer.listEnvsForOrg`, so the sweep's inner loop never revisits it
|
||||
// and never HDELs its counts entry. Without the visited-set GC the
|
||||
// counts hash retains the env's last-known stale count forever and the
|
||||
// gauge stays permanently elevated. At cursor wrap we diff the hash
|
||||
// against the cycle's visited set and HDEL the difference.
|
||||
//
|
||||
// Storage is owned by this class rather than added to MollifierBuffer
|
||||
// because the keys are sweep-internal — the buffer abstracts the
|
||||
// drainer/queue state, this abstracts sweep state. They share a
|
||||
// namespace prefix but no API surface.
|
||||
|
||||
export interface StaleSweepStateStore {
|
||||
readCursor(): Promise<number>;
|
||||
writeCursor(value: number): Promise<void>;
|
||||
/** Replaces the cycle's frozen org_list. Called at cursor=0. */
|
||||
rebuildOrgList(orgs: string[]): Promise<void>;
|
||||
/** Returns up to `count` org IDs starting at `start`, plus the LIST's total length. */
|
||||
readOrgListSlice(start: number, count: number): Promise<{ orgs: string[]; total: number }>;
|
||||
/** HSET when count > 0, HDEL when count === 0 (so the snapshot reflects current truth). */
|
||||
setEnvStaleCount(envId: string, count: number): Promise<void>;
|
||||
readAllEnvStaleCounts(): Promise<Map<string, number>>;
|
||||
/** SADD `envId` to the current cycle's visited set. Called once per env scanned per tick. */
|
||||
markEnvVisited(envId: string): Promise<void>;
|
||||
/**
|
||||
* HDEL every env in the counts hash that is NOT in the visited set, then
|
||||
* DEL the visited set. Called when the cursor wraps (cycle ends) so
|
||||
* envs that fully drained mid-cycle get cleaned out of the gauge.
|
||||
*/
|
||||
reconcileVisited(): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
const CURSOR_KEY = "mollifier:stale_sweep:cursor";
|
||||
const ORG_LIST_KEY = "mollifier:stale_sweep:org_list";
|
||||
const COUNTS_KEY = "mollifier:stale_sweep:counts";
|
||||
const VISITED_KEY = "mollifier:stale_sweep:visited";
|
||||
|
||||
export class MollifierStaleSweepState implements StaleSweepStateStore {
|
||||
private readonly redis: Redis;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(options: {
|
||||
redisOptions: RedisOptions;
|
||||
logger?: Logger;
|
||||
maxRetriesPerRequest?: number;
|
||||
}) {
|
||||
this.logger = options.logger ?? new Logger("MollifierStaleSweepState", "debug");
|
||||
this.redis = createRedisClient(
|
||||
{ ...options.redisOptions, maxRetriesPerRequest: options.maxRetriesPerRequest ?? 20 },
|
||||
{
|
||||
onError: (error) => {
|
||||
this.logger.error("MollifierStaleSweepState redis client error:", { error });
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async readCursor(): Promise<number> {
|
||||
const raw = await this.redis.get(CURSOR_KEY);
|
||||
if (raw === null) return 0;
|
||||
const n = Number.parseInt(raw, 10);
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
async writeCursor(value: number): Promise<void> {
|
||||
await this.redis.set(CURSOR_KEY, String(value));
|
||||
}
|
||||
|
||||
async rebuildOrgList(orgs: string[]): Promise<void> {
|
||||
// DEL + RPUSH in a pipeline — close enough to atomic for an
|
||||
// observational sweep (the inFlight guard at startStaleSweepInterval
|
||||
// serialises sweep passes; nothing else writes these keys).
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.del(ORG_LIST_KEY);
|
||||
if (orgs.length > 0) {
|
||||
pipeline.rpush(ORG_LIST_KEY, ...orgs);
|
||||
}
|
||||
await pipeline.exec();
|
||||
}
|
||||
|
||||
async readOrgListSlice(start: number, count: number): Promise<{ orgs: string[]; total: number }> {
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.lrange(ORG_LIST_KEY, start, start + count - 1);
|
||||
pipeline.llen(ORG_LIST_KEY);
|
||||
const results = await pipeline.exec();
|
||||
// `pipeline.exec()` returning null is the abort-on-broken-pipe path.
|
||||
// Surface it as a thrown error — the previous `return { orgs: [], total: 0 }`
|
||||
// looked indistinguishable from a genuinely empty org list to the
|
||||
// caller (`runStaleSweepOnce`), which then wrote cursor=0, reconciled
|
||||
// visited envs against the empty result, and cleared the stale-entry
|
||||
// gauge. That hid real Redis problems and silenced the alerts the
|
||||
// sweep exists to raise.
|
||||
if (!results) {
|
||||
throw new Error("MollifierStaleSweepState.readOrgListSlice: pipeline.exec returned null");
|
||||
}
|
||||
const [lrangeErr, lrangeRes] = results[0] as [Error | null, string[] | null];
|
||||
const [llenErr, llenRes] = results[1] as [Error | null, number | null];
|
||||
if (lrangeErr || llenErr) {
|
||||
this.logger.error("MollifierStaleSweepState.readOrgListSlice failed", {
|
||||
lrangeErr: lrangeErr?.message,
|
||||
llenErr: llenErr?.message,
|
||||
});
|
||||
// Same reasoning as the null-result path above — propagate the
|
||||
// failure so the sweep's interval wrapper records a failed cycle
|
||||
// and the durable cursor / counts hash stay untouched.
|
||||
throw lrangeErr ?? llenErr ?? new Error("MollifierStaleSweepState.readOrgListSlice failed");
|
||||
}
|
||||
return { orgs: lrangeRes ?? [], total: llenRes ?? 0 };
|
||||
}
|
||||
|
||||
async setEnvStaleCount(envId: string, count: number): Promise<void> {
|
||||
if (count > 0) {
|
||||
await this.redis.hset(COUNTS_KEY, envId, String(count));
|
||||
} else {
|
||||
await this.redis.hdel(COUNTS_KEY, envId);
|
||||
}
|
||||
}
|
||||
|
||||
async readAllEnvStaleCounts(): Promise<Map<string, number>> {
|
||||
const raw = await this.redis.hgetall(COUNTS_KEY);
|
||||
const out = new Map<string, number>();
|
||||
for (const [envId, value] of Object.entries(raw)) {
|
||||
const n = Number.parseInt(value, 10);
|
||||
if (Number.isFinite(n)) out.set(envId, n);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async markEnvVisited(envId: string): Promise<void> {
|
||||
await this.redis.sadd(VISITED_KEY, envId);
|
||||
}
|
||||
|
||||
async reconcileVisited(): Promise<void> {
|
||||
// HKEYS + SMEMBERS in a pipeline, then HDEL the difference locally.
|
||||
// For typical fleet sizes (counts and visited both bounded by the
|
||||
// count of buffered envs) this is well within a single RTT plus one
|
||||
// small HDEL.
|
||||
const pipeline = this.redis.pipeline();
|
||||
pipeline.hkeys(COUNTS_KEY);
|
||||
pipeline.smembers(VISITED_KEY);
|
||||
const results = await pipeline.exec();
|
||||
if (!results) return;
|
||||
const [hkeysErr, hkeysRes] = results[0] as [Error | null, string[] | null];
|
||||
const [smembersErr, smembersRes] = results[1] as [Error | null, string[] | null];
|
||||
if (hkeysErr || smembersErr) {
|
||||
this.logger.error("MollifierStaleSweepState.reconcileVisited failed", {
|
||||
hkeysErr: hkeysErr?.message,
|
||||
smembersErr: smembersErr?.message,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const hashEnvs = hkeysRes ?? [];
|
||||
const visited = new Set(smembersRes ?? []);
|
||||
const orphans = hashEnvs.filter((envId) => !visited.has(envId));
|
||||
const cleanup = this.redis.pipeline();
|
||||
if (orphans.length > 0) {
|
||||
cleanup.hdel(COUNTS_KEY, ...orphans);
|
||||
}
|
||||
cleanup.del(VISITED_KEY);
|
||||
await cleanup.exec();
|
||||
}
|
||||
|
||||
async clearAll(): Promise<void> {
|
||||
await this.redis.del(CURSOR_KEY, ORG_LIST_KEY, COUNTS_KEY, VISITED_KEY);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.redis.quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { getMeter } from "@internal/tracing";
|
||||
|
||||
const meter = getMeter("mollifier");
|
||||
|
||||
export const mollifierDecisionsCounter = meter.createCounter("mollifier.decisions", {
|
||||
description: "Count of mollifier gate decisions by outcome",
|
||||
});
|
||||
|
||||
export type DecisionOutcome = "pass_through" | "shadow_log" | "mollify";
|
||||
export type DecisionReason = "per_env_rate";
|
||||
|
||||
export type RecordDecisionOptions = {
|
||||
reason?: DecisionReason;
|
||||
// Whether the org has the per-org mollifier flag enabled. Emitted as the
|
||||
// bounded `enrolled` label so we can see how often enrolled orgs pass
|
||||
// through instead of mollifying — the whole point of this instrumentation.
|
||||
enrolled: boolean;
|
||||
// Org id, attached as the `org` label ONLY when `enrolled` is true. The
|
||||
// enrolled cohort is capped operationally (<= 10 orgs), so this stays
|
||||
// low-cardinality. It must NEVER be attached for non-enrolled orgs — that
|
||||
// would fan the metric out across every org id in production (unbounded;
|
||||
// the same high-cardinality ban that keeps envId/orgId off the other
|
||||
// mollifier metrics). The guard lives in `decisionLabels`, so callers can
|
||||
// pass orgId unconditionally.
|
||||
orgId?: string;
|
||||
};
|
||||
|
||||
// Pure: builds the metric label set for a gate decision. Extracted from
|
||||
// `recordDecision` so the org-only-when-enrolled cardinality guard is
|
||||
// unit-testable without standing up an OTel meter.
|
||||
export function decisionLabels(
|
||||
outcome: DecisionOutcome,
|
||||
opts: RecordDecisionOptions
|
||||
): Record<string, string> {
|
||||
return {
|
||||
outcome,
|
||||
enrolled: opts.enrolled ? "true" : "false",
|
||||
...(opts.reason ? { reason: opts.reason } : {}),
|
||||
...(opts.enrolled && opts.orgId ? { org: opts.orgId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function recordDecision(outcome: DecisionOutcome, opts: RecordDecisionOptions): void {
|
||||
mollifierDecisionsCounter.add(1, decisionLabels(outcome, opts));
|
||||
}
|
||||
|
||||
// Counts subscriptions hitting `/realtime/v1/runs/<id>` for a run that
|
||||
// lives only in the mollifier buffer (no PG row yet). The route opens
|
||||
// the Electric stream anyway so the eventual drainer-INSERT propagates
|
||||
// to the client; this counter is the signal of how often customers
|
||||
// subscribe inside the buffered window.
|
||||
export const realtimeBufferedSubscriptionsCounter = meter.createCounter(
|
||||
"mollifier.realtime_subscriptions.buffered",
|
||||
{
|
||||
description:
|
||||
"Realtime subscriptions opened against a runId that exists only in the mollifier buffer",
|
||||
}
|
||||
);
|
||||
|
||||
// No `envId` attribute — `envId` is a banned high-cardinality metric
|
||||
// label per the repo's OTel rules. The structured warn log emitted
|
||||
// alongside the counter tick (in `mollifierStaleSweep.server.ts`)
|
||||
// carries the envId / orgId / runId for forensic drill-down; the
|
||||
// metric stays an aggregate.
|
||||
export function recordRealtimeBufferedSubscription(): void {
|
||||
realtimeBufferedSubscriptionsCounter.add(1);
|
||||
}
|
||||
|
||||
// Counts buffer entries that have been waiting in the queue ZSET longer
|
||||
// than the configured stale threshold. Useful for historical "stale
|
||||
// events over time" views, but not directly alertable on its own — a
|
||||
// single stuck entry observed by N sweep ticks adds N to the counter,
|
||||
// so `rate()` over an alerting window reflects (entries × ticks), not
|
||||
// "entries that are stale right now".
|
||||
export const staleEntriesCounter = meter.createCounter("mollifier.stale_entries", {
|
||||
description: "Mollifier buffer entries whose dwell exceeds the stale threshold (per sweep pass)",
|
||||
});
|
||||
|
||||
// No `envId` attribute — see comment above.
|
||||
export function recordStaleEntry(): void {
|
||||
staleEntriesCounter.add(1);
|
||||
}
|
||||
|
||||
// Alertable signal: the total count of stale entries observed by the
|
||||
// latest sweep. The sweep snapshots the full picture on each pass so
|
||||
// the gauge drops back to 0 when the drainer catches up instead of
|
||||
// staying latched. Recommended alert:
|
||||
// mollifier_stale_entries_current > 0 for 5m
|
||||
export const staleEntriesGauge = meter.createObservableGauge("mollifier.stale_entries.current", {
|
||||
description:
|
||||
"Buffer entries whose dwell exceeds the stale threshold, as observed by the latest sweep pass",
|
||||
});
|
||||
|
||||
let latestStaleTotal = 0;
|
||||
|
||||
export function reportStaleEntrySnapshot(snapshot: Map<string, number>): void {
|
||||
// Sum across envs. Per-env breakdown is intentionally NOT emitted as
|
||||
// a metric label (high-cardinality); the structured warn log lines
|
||||
// from the sweep carry per-env detail for ops to drill down.
|
||||
let total = 0;
|
||||
for (const count of snapshot.values()) {
|
||||
total += count;
|
||||
}
|
||||
latestStaleTotal = total;
|
||||
}
|
||||
|
||||
meter.addBatchObservableCallback(
|
||||
(result) => {
|
||||
result.observe(staleEntriesGauge, latestStaleTotal);
|
||||
},
|
||||
[staleEntriesGauge]
|
||||
);
|
||||
|
||||
// Observability gauge for entries currently in DRAINING state — popped
|
||||
// by the drainer but not yet acked/failed/requeued. Backed by the
|
||||
// `mollifier:draining` ZSET (see `MollifierBuffer.getDrainingCount`)
|
||||
// and polled by the loop in `mollifierDrainingGaugeLoop.server.ts`.
|
||||
//
|
||||
// Useful for:
|
||||
// - "Is anything mid-drain right now?" panels
|
||||
// - Post-crash forensics ("how many entries got stranded by that ECS OOM?")
|
||||
// - Alerting: a sustained non-zero with no drainer progress is a stall
|
||||
//
|
||||
// No `envId` attribute — same high-cardinality constraint as the other
|
||||
// mollifier gauges. The per-entry hash carries env/org for drill-down.
|
||||
export const drainingCountGauge = meter.createObservableGauge("mollifier.draining.current", {
|
||||
description:
|
||||
"Mollifier buffer entries currently in DRAINING state (popped but not yet acked/failed/requeued)",
|
||||
});
|
||||
|
||||
let latestDrainingCount = 0;
|
||||
|
||||
export function reportDrainingCount(count: number): void {
|
||||
latestDrainingCount = count;
|
||||
}
|
||||
|
||||
meter.addBatchObservableCallback(
|
||||
(result) => {
|
||||
result.observe(drainingCountGauge, latestDrainingCount);
|
||||
},
|
||||
[drainingCountGauge]
|
||||
);
|
||||
|
||||
// Electric SQL's shape-stream protocol adds a `handle=` query param on
|
||||
// every reconnect after the initial GET. Gating the realtime-buffered
|
||||
// log/counter on its absence keeps the signal at one tick per
|
||||
// subscription instead of one tick per ~20s live-poll iteration —
|
||||
// without it the counter would over-count by the long-poll factor.
|
||||
export function isInitialBufferedSubscriptionRequest(url: string | URL): boolean {
|
||||
const u = typeof url === "string" ? new URL(url) : url;
|
||||
return !u.searchParams.has("handle");
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import type { GateInputs, TripDecision, TripEvaluator } from "./mollifierGate.server";
|
||||
|
||||
export type TripEvaluatorOptions = {
|
||||
windowMs: number;
|
||||
threshold: number;
|
||||
holdMs: number;
|
||||
};
|
||||
|
||||
export type CreateRealTripEvaluatorDeps = {
|
||||
getBuffer: () => MollifierBuffer | null;
|
||||
options: () => TripEvaluatorOptions;
|
||||
};
|
||||
|
||||
export function createRealTripEvaluator(deps: CreateRealTripEvaluatorDeps): TripEvaluator {
|
||||
return async (inputs: GateInputs): Promise<TripDecision> => {
|
||||
const buffer = deps.getBuffer();
|
||||
if (!buffer) return { divert: false };
|
||||
|
||||
const opts = deps.options();
|
||||
|
||||
try {
|
||||
const { tripped, count } = await buffer.evaluateTrip(inputs.envId, opts);
|
||||
if (!tripped) return { divert: false };
|
||||
|
||||
return {
|
||||
divert: true,
|
||||
reason: "per_env_rate",
|
||||
count,
|
||||
threshold: opts.threshold,
|
||||
windowMs: opts.windowMs,
|
||||
holdMs: opts.holdMs,
|
||||
};
|
||||
} catch (err) {
|
||||
// Deliberate: no error counter here. Shadow mode means a silent miss is
|
||||
// harmless — fail-open is the safe direction. The error log + Sentry
|
||||
// capture is sufficient operability while this runs in shadow mode. Revisit
|
||||
// once buffer writes are the primary path and a missed evaluation has cost.
|
||||
logger.error("mollifier trip evaluator: fail-open on error", {
|
||||
envId: inputs.envId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return { divert: false };
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
||||
import type { PrismaClientOrTransaction, PrismaReplicaClient } from "~/db.server";
|
||||
import { $replica as defaultReplica, prisma as defaultWriter } from "~/db.server";
|
||||
import { runStore } from "~/v3/runStore.server";
|
||||
import { getMollifierBuffer as defaultGetBuffer } from "./mollifierBuffer.server";
|
||||
|
||||
// Discriminated-union resolver used by mutation routes' `findResource`.
|
||||
// The route builder treats a null return from `findResource` as a 404
|
||||
// BEFORE the action handler runs (`apiBuilder.server.ts:321`), so we
|
||||
// must check BOTH the PG canonical store and the mollifier buffer here
|
||||
// — otherwise a buffered run can't be cancelled / mutated even though
|
||||
// the underlying mutateWithFallback flow would handle it correctly.
|
||||
//
|
||||
// (Regression: before extracting this helper the cancel route had
|
||||
// `findResource: async () => null`, which made every cancel 404 before
|
||||
// the action ran. The helper makes the lookup unit-testable.)
|
||||
export type ResolvedRunForMutation =
|
||||
| { source: "pg"; friendlyId: string }
|
||||
| { source: "buffer"; friendlyId: string };
|
||||
|
||||
export type ResolveRunForMutationDeps = {
|
||||
prismaReplica?: PrismaReplicaClient;
|
||||
prismaWriter?: PrismaClientOrTransaction;
|
||||
getBuffer?: () => MollifierBuffer | null;
|
||||
};
|
||||
|
||||
export async function resolveRunForMutation(input: {
|
||||
runParam: string;
|
||||
environmentId: string;
|
||||
organizationId: string;
|
||||
deps?: ResolveRunForMutationDeps;
|
||||
}): Promise<ResolvedRunForMutation | null> {
|
||||
const replica = input.deps?.prismaReplica ?? defaultReplica;
|
||||
const writer = input.deps?.prismaWriter ?? defaultWriter;
|
||||
const getBuffer = input.deps?.getBuffer ?? defaultGetBuffer;
|
||||
|
||||
const pgRun = await runStore.findRun(
|
||||
{ friendlyId: input.runParam, runtimeEnvironmentId: input.environmentId },
|
||||
{ select: { friendlyId: true } },
|
||||
replica
|
||||
);
|
||||
if (pgRun) return { source: "pg", friendlyId: pgRun.friendlyId };
|
||||
|
||||
const buffer = getBuffer();
|
||||
|
||||
if (buffer) {
|
||||
const entry = await buffer.getEntry(input.runParam);
|
||||
if (entry && entry.envId === input.environmentId && entry.orgId === input.organizationId) {
|
||||
return { source: "buffer", friendlyId: input.runParam };
|
||||
}
|
||||
}
|
||||
|
||||
// Replica + buffer both missed. Before declaring "not found" (which the
|
||||
// route builder converts to a hard 404 *before* the action handler runs,
|
||||
// so the downstream `mutateWithFallback` writer-recovery never gets a
|
||||
// chance to fire), do one final probe against the writer. This catches
|
||||
// two cases:
|
||||
// 1. Replica lag on a freshly-created PG row.
|
||||
// 2. A buffered run that materialised in the window between the
|
||||
// replica read and our buffer check (the entry was ack'd and the
|
||||
// hash is mid-grace-TTL but our getEntry returned null due to
|
||||
// lookup-by-friendlyId timing).
|
||||
// Without this, the resolver returns null in degraded states that the
|
||||
// downstream mutateWithFallback flow would otherwise handle correctly.
|
||||
const writerRun = await runStore.findRun(
|
||||
{ friendlyId: input.runParam, runtimeEnvironmentId: input.environmentId },
|
||||
{ select: { friendlyId: true } },
|
||||
writer
|
||||
);
|
||||
if (writerRun) return { source: "pg", friendlyId: writerRun.friendlyId };
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { SyntheticRun } from "./readFallback.server";
|
||||
|
||||
// Buffered runs have no execution data — the drainer hasn't materialised
|
||||
// the PG row and the worker hasn't started. The SDK-facing read routes
|
||||
// still need to return a span/trace shape that satisfies their response
|
||||
// schemas; these helpers build that minimal shape from the buffered
|
||||
// SyntheticRun.
|
||||
//
|
||||
// CANCELED and FAILED are terminal states: a FAILED buffered run is
|
||||
// errored (drainer exhausted retries or the gate rejected it) and must
|
||||
// not signal "still in progress." The flags below mirror
|
||||
// syntheticTrace.server.ts so the SDK contract stays consistent across
|
||||
// the three read paths (spans, trace, dashboard trace presenter).
|
||||
|
||||
function deriveTerminalFlags(status: SyntheticRun["status"]): {
|
||||
isError: boolean;
|
||||
isPartial: boolean;
|
||||
isCancelled: boolean;
|
||||
} {
|
||||
const isCancelled = status === "CANCELED";
|
||||
const isFailed = status === "FAILED";
|
||||
return {
|
||||
isError: isFailed,
|
||||
isPartial: !isCancelled && !isFailed,
|
||||
isCancelled,
|
||||
};
|
||||
}
|
||||
|
||||
// Body for GET /api/v1/runs/:runId/spans/:spanId when the run is buffered
|
||||
// and `:spanId` has already been verified against `buffered.spanId` by the
|
||||
// route. Pure function so the route layer just authenticates, resolves
|
||||
// the run, validates the spanId, and forwards the buffered run here.
|
||||
export function buildSyntheticSpanDetailBody(buffered: SyntheticRun) {
|
||||
const flags = deriveTerminalFlags(buffered.status);
|
||||
return {
|
||||
spanId: buffered.spanId,
|
||||
parentId: buffered.parentSpanId ?? null,
|
||||
runId: buffered.friendlyId,
|
||||
message: buffered.taskIdentifier ?? "",
|
||||
...flags,
|
||||
level: "TRACE" as const,
|
||||
startTime: buffered.createdAt,
|
||||
durationMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Body for GET /api/v1/runs/:runId/trace when the run is buffered.
|
||||
// Returns the `{ trace: { traceId, rootSpan } }` envelope expected by the
|
||||
// SDK's RetrieveRunTraceResponseBody schema.
|
||||
export function buildSyntheticTraceBody(buffered: SyntheticRun) {
|
||||
const flags = deriveTerminalFlags(buffered.status);
|
||||
return {
|
||||
trace: {
|
||||
traceId: buffered.traceId ?? "",
|
||||
rootSpan: {
|
||||
id: buffered.spanId ?? "",
|
||||
runId: buffered.friendlyId,
|
||||
data: {
|
||||
message: buffered.taskIdentifier ?? "",
|
||||
taskSlug: buffered.taskIdentifier ?? undefined,
|
||||
events: [] as unknown[],
|
||||
startTime: buffered.createdAt,
|
||||
duration: 0,
|
||||
...flags,
|
||||
level: "TRACE" as const,
|
||||
queueName: buffered.queue ?? undefined,
|
||||
machinePreset: buffered.machinePreset ?? undefined,
|
||||
},
|
||||
children: [] as unknown[],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { MollifierBuffer } from "@trigger.dev/redis-worker";
|
||||
import type { PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getMollifierBuffer } from "./mollifierBuffer.server";
|
||||
// Use the webapp-side wrapper (not `deserialiseSnapshot` from
|
||||
// @trigger.dev/redis-worker directly) so this file shares a single
|
||||
// deserialisation path with readFallback.server.ts. The two are
|
||||
// behaviourally identical today (both wrap `JSON.parse`), but pinning
|
||||
// the shared helper keeps the two read-side modules from drifting if
|
||||
// snapshot encoding ever changes.
|
||||
import { deserialiseMollifierSnapshot } from "./mollifierSnapshot.server";
|
||||
|
||||
// Validated subset of a mollifier snapshot — just the fields needed to
|
||||
// rebuild a canonical run-detail URL for a buffered run. Anything else
|
||||
// in the payload is ignored. `safeParse` against this schema replaces
|
||||
// the ad-hoc `as Record<string, unknown>` + `typeof === "string"` checks
|
||||
// that the redirect path used to do by hand; missing or wrong-typed
|
||||
// fields collapse into a single `parsed.success === false` branch.
|
||||
const BufferedSnapshotSchema = z.object({
|
||||
spanId: z.string().optional(),
|
||||
environment: z.object({
|
||||
slug: z.string(),
|
||||
project: z.object({ slug: z.string() }),
|
||||
organization: z.object({ slug: z.string() }),
|
||||
}),
|
||||
});
|
||||
|
||||
export type BufferedRunRedirectInfo = {
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
spanId: string | undefined;
|
||||
};
|
||||
|
||||
export type FindBufferedRunRedirectInfoDeps = {
|
||||
getBuffer?: () => MollifierBuffer | null;
|
||||
prismaClient?: PrismaClientOrTransaction;
|
||||
};
|
||||
|
||||
// Resolve the org/project/env slugs needed to build the canonical run-detail
|
||||
// URL for a buffered run. Used by the short-URL redirect routes
|
||||
// (`runs.$runParam`, `@.runs.$runParam`, `projects.v3.$projectRef.runs.$runParam`)
|
||||
// so a customer clicking the trigger-API-returned run link doesn't 404
|
||||
// during the buffered window.
|
||||
//
|
||||
// Authorisation: PG query confirms the requesting user belongs to the
|
||||
// organisation the buffer entry says owns the run. Without this check a
|
||||
// known runId would leak slugs.
|
||||
export async function findBufferedRunRedirectInfo(
|
||||
args: {
|
||||
runFriendlyId: string;
|
||||
userId: string;
|
||||
// Admin impersonation paths bypass org-membership; mirrors the existing
|
||||
// PG-side admin route behaviour (`@.runs.$runParam` doesn't filter by
|
||||
// org membership in the PG query either).
|
||||
skipOrgMembershipCheck?: boolean;
|
||||
},
|
||||
deps: FindBufferedRunRedirectInfoDeps = {}
|
||||
): Promise<BufferedRunRedirectInfo | null> {
|
||||
const buffer = (deps.getBuffer ?? getMollifierBuffer)();
|
||||
const prismaClient = deps.prismaClient ?? prisma;
|
||||
if (!buffer) return null;
|
||||
|
||||
let entry;
|
||||
try {
|
||||
entry = await buffer.getEntry(args.runFriendlyId);
|
||||
} catch (err) {
|
||||
logger.warn("buffered redirect: buffer.getEntry failed", {
|
||||
runFriendlyId: args.runFriendlyId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!entry) return null;
|
||||
|
||||
if (!args.skipOrgMembershipCheck) {
|
||||
const member = await prismaClient.orgMember.findFirst({
|
||||
where: { userId: args.userId, organizationId: entry.orgId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!member) return null;
|
||||
}
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = deserialiseMollifierSnapshot(entry.payload);
|
||||
} catch (err) {
|
||||
logger.warn("buffered redirect: snapshot deserialise failed", {
|
||||
runFriendlyId: args.runFriendlyId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = BufferedSnapshotSchema.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
// Either the snapshot is from a different writer that doesn't carry
|
||||
// environment slugs (in which case we genuinely can't build a URL)
|
||||
// or a buffer-format drift snuck through. Log at debug; the caller
|
||||
// 404s and the user sees the standard not-found page, not a 500.
|
||||
logger.debug("buffered redirect: snapshot shape mismatch", {
|
||||
runFriendlyId: args.runFriendlyId,
|
||||
issues: parsed.error.issues.map((issue) => ({
|
||||
path: issue.path.join("."),
|
||||
code: issue.code,
|
||||
})),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
organizationSlug: parsed.data.environment.organization.slug,
|
||||
projectSlug: parsed.data.environment.project.slug,
|
||||
environmentSlug: parsed.data.environment.slug,
|
||||
spanId: parsed.data.spanId,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { TaskRun } from "@trigger.dev/database";
|
||||
import type { SyntheticRun } from "./readFallback.server";
|
||||
|
||||
export type SyntheticReplayTaskRun = TaskRun & {
|
||||
project: { slug: string; organization: { slug: string } };
|
||||
runtimeEnvironment: { slug: string };
|
||||
};
|
||||
|
||||
// Adapt a buffered-run snapshot into the TaskRun-shaped input that
|
||||
// `ReplayTaskRunService.call` expects. ReplayTaskRunService builds the
|
||||
// new run's traceparent as `00-${existingTaskRun.traceId}-${existingTaskRun.spanId}-01`
|
||||
// without guarding for undefined, so a synthetic with missing traceId
|
||||
// or spanId (older snapshots — both fields are documented optional on
|
||||
// `SyntheticRun`) would produce `00-undefined-undefined-01`, an invalid
|
||||
// W3C traceparent that OTel silently drops, severing the replay's trace
|
||||
// link to the original run.
|
||||
//
|
||||
// Returns null when those fields are missing — the caller surfaces this
|
||||
// as "Run not found" so the customer retries once the drainer has
|
||||
// materialised the PG row, where traceId/spanId are guaranteed present.
|
||||
export function buildSyntheticReplayTaskRun(args: {
|
||||
synthetic: SyntheticRun;
|
||||
envRow: {
|
||||
slug: string;
|
||||
project: { slug: string; organization: { slug: string } };
|
||||
};
|
||||
}): SyntheticReplayTaskRun | null {
|
||||
const { synthetic, envRow } = args;
|
||||
if (!synthetic.traceId || !synthetic.spanId) return null;
|
||||
return {
|
||||
// The double `as unknown as TaskRun` cast is load-bearing — a direct
|
||||
// `synthetic as TaskRun` won't compile. `SyntheticRun` carries the
|
||||
// subset of fields that `ReplayTaskRunService.call` actually reads
|
||||
// (the contract is enumerated on the SyntheticRun type comment in
|
||||
// readFallback.server.ts), but its shape is not structurally
|
||||
// assignable to the full Prisma `TaskRun` row: optional vs required
|
||||
// fields diverge, several PG columns (number, batchId variants,
|
||||
// status enum widening) are deliberately absent or narrower on the
|
||||
// synthetic. Routing it through `unknown` is the explicit "we know
|
||||
// this is a subset, we've audited which fields are read" signal,
|
||||
// and the traceId/spanId guard above prevents the only field
|
||||
// ReplayTaskRunService consumes that would corrupt downstream
|
||||
// behaviour (the OTel traceparent) when undefined.
|
||||
...(synthetic as unknown as TaskRun),
|
||||
project: {
|
||||
slug: envRow.project.slug,
|
||||
organization: { slug: envRow.project.organization.slug },
|
||||
},
|
||||
runtimeEnvironment: { slug: envRow.slug },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { SyntheticRun } from "./readFallback.server";
|
||||
|
||||
// Synthesise the run-detail page's `run` header shape (the NavBar +
|
||||
// status badge + Cancel-button gate) from a buffered run snapshot. The
|
||||
// shape matches `RunPresenter.getRun`'s `runData` — keep this in sync
|
||||
// when fields are added there.
|
||||
//
|
||||
// CANCELED and FAILED state is reflected back from
|
||||
// `SyntheticRun.cancelledAt` / `status` so terminal buffered runs show
|
||||
// the correct status in the NavBar + isFinished:true (which collapses
|
||||
// the Cancel button on the page header) before the drainer materialises
|
||||
// the PG row. This mirrors what `buildSyntheticSpanRun` does for the
|
||||
// right-side details panel — the SyntheticRun.cancelledAt contract
|
||||
// comment in readFallback.server.ts names this exact UI surface.
|
||||
//
|
||||
// FAILED status maps to `SYSTEM_FAILURE` to match the drainer's
|
||||
// non-retryable terminal path, which is what `buildSyntheticSpanRun`
|
||||
// uses too. Symmetric across the header + span-detail panel so an
|
||||
// admin doesn't see "Pending" + "FAILED" simultaneously on the same
|
||||
// run.
|
||||
export function buildSyntheticRunHeader(args: {
|
||||
run: SyntheticRun;
|
||||
environment: {
|
||||
id: string;
|
||||
organizationId: string;
|
||||
type: "PRODUCTION" | "DEVELOPMENT" | "STAGING" | "PREVIEW";
|
||||
slug: string;
|
||||
};
|
||||
}) {
|
||||
const { run, environment } = args;
|
||||
const isCancelled = run.status === "CANCELED";
|
||||
const isFailed = run.status === "FAILED";
|
||||
|
||||
return {
|
||||
// `id` mirrors RunPresenter.getRun's runData (the PG path), which
|
||||
// is the internal cuid — not the friendlyId. SyntheticRun.id is
|
||||
// already the cuid (RunId.fromFriendlyId(entry.runId) in
|
||||
// readFallback.server.ts) so the admin debug tooltip on the run
|
||||
// detail page shows the same format for buffered + materialised
|
||||
// runs.
|
||||
id: run.id,
|
||||
number: 1,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId ?? "",
|
||||
spanId: run.spanId ?? "",
|
||||
status: isCancelled
|
||||
? ("CANCELED" as const)
|
||||
: isFailed
|
||||
? ("SYSTEM_FAILURE" as const)
|
||||
: ("PENDING" as const),
|
||||
isFinished: isCancelled || isFailed,
|
||||
startedAt: null,
|
||||
// Symmetric with `buildSyntheticSpanRun` and the
|
||||
// `ApiRetrieveRunPresenter` synth path. The run-detail route
|
||||
// derives `isCompleted` from `completedAt !== null` and gates SSE
|
||||
// live-reloading on it (`route.tsx:459`, `:551`); leaving
|
||||
// `completedAt` null for FAILED would keep a terminal buffered run
|
||||
// live-reloading forever. PG-resident SYSTEM_FAILURE rows always
|
||||
// have completedAt set, so fall back to createdAt (the buffer
|
||||
// entry has no separate failedAt — closest proxy for when the
|
||||
// terminal state landed).
|
||||
completedAt: run.cancelledAt ?? (isFailed ? run.createdAt : null),
|
||||
logsDeletedAt: null,
|
||||
rootTaskRun: null,
|
||||
parentTaskRun: null,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
organizationId: environment.organizationId,
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
userId: undefined,
|
||||
userName: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { prettyPrintPacket, RunAnnotations } from "@trigger.dev/core/v3";
|
||||
import { getMaxDuration } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
extractIdempotencyKeyScope,
|
||||
getUserProvidedIdempotencyKey,
|
||||
} from "@trigger.dev/core/v3/serverOnly";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3/schemas";
|
||||
import type { SpanRun } from "~/presenters/v3/SpanPresenter.server";
|
||||
import type { SyntheticRun } from "./readFallback.server";
|
||||
|
||||
// `SyntheticRun.machinePreset` is sourced from the snapshot payload as
|
||||
// a plain string, but `SpanRun.machinePreset` is the narrowed enum.
|
||||
// Validate against the canonical enum so an unknown / stale preset
|
||||
// string collapses to undefined rather than fighting the type checker.
|
||||
function narrowMachinePreset(value: string | undefined): SpanRun["machinePreset"] {
|
||||
if (value === undefined) return undefined;
|
||||
const parsed = MachinePresetName.safeParse(value);
|
||||
return parsed.success ? parsed.data : undefined;
|
||||
}
|
||||
|
||||
// Synthesise a SpanRun-shaped object from a buffered run so the run-detail
|
||||
// page's right-side details panel renders identically to a PG-resident
|
||||
// run. The shape matches `SpanPresenter.getRun`'s return value;
|
||||
// buffered-irrelevant fields (output, attempts, schedule, session,
|
||||
// region, batch) are filled with sensible defaults, while terminal state
|
||||
// (CANCELED / FAILED) is reflected into `status`, `isFinished`, `isError`
|
||||
// and `error` so a finished buffered run does not render as PENDING.
|
||||
//
|
||||
// Pretty-printing for payload and metadata mirrors SpanPresenter so the
|
||||
// UI receives data in the same shape. Buffered runs cannot use the
|
||||
// `application/store` packet path (no R2 object yet) so we treat raw
|
||||
// snapshot fields as inline packets.
|
||||
export async function buildSyntheticSpanRun(args: {
|
||||
run: SyntheticRun;
|
||||
environment: {
|
||||
id: string;
|
||||
slug: string;
|
||||
type: "PRODUCTION" | "DEVELOPMENT" | "STAGING" | "PREVIEW";
|
||||
};
|
||||
}): Promise<SpanRun> {
|
||||
const { run, environment } = args;
|
||||
|
||||
const payload =
|
||||
typeof run.payload !== "undefined" && run.payload !== null
|
||||
? await prettyPrintPacket(run.payload, run.payloadType ?? undefined)
|
||||
: undefined;
|
||||
|
||||
// Nullish check, not truthy — matches the payload branch above so an
|
||||
// intentionally-empty packet (e.g. metadata: "") still gets handed to
|
||||
// `prettyPrintPacket` and renders consistently. A truthy check would
|
||||
// drop the empty-string case and the two paths would diverge.
|
||||
const metadata =
|
||||
typeof run.metadata !== "undefined" && run.metadata !== null
|
||||
? await prettyPrintPacket(run.metadata, run.metadataType, {
|
||||
filteredKeys: ["$$streams", "$$streamsVersion", "$$streamsBaseUrl"],
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const idempotencyShape = {
|
||||
idempotencyKey: run.idempotencyKey ?? null,
|
||||
idempotencyKeyExpiresAt: null,
|
||||
idempotencyKeyOptions: run.idempotencyKeyOptions ?? null,
|
||||
};
|
||||
|
||||
const idempotencyKey = getUserProvidedIdempotencyKey(idempotencyShape);
|
||||
const idempotencyKeyScope = extractIdempotencyKeyScope(idempotencyShape);
|
||||
const idempotencyKeyStatus: SpanRun["idempotencyKeyStatus"] = idempotencyKey
|
||||
? "active"
|
||||
: idempotencyKeyScope
|
||||
? "inactive"
|
||||
: undefined;
|
||||
|
||||
const taskKind = RunAnnotations.safeParse(run.annotations).data?.taskKind;
|
||||
const isAgentRun = taskKind === "AGENT";
|
||||
const isScheduled = taskKind === "SCHEDULED";
|
||||
|
||||
const queueName = run.queue ?? "task/";
|
||||
const isCancelled = run.status === "CANCELED";
|
||||
const isFailed = run.status === "FAILED";
|
||||
|
||||
// The run-detail panel derives terminal/error state from `status`,
|
||||
// `isFinished` and `isError` (SpanPresenter.getRun -> isFinalRunStatus /
|
||||
// isFailedRunStatus). Buffered FAILED runs surface as SYSTEM_FAILURE to
|
||||
// match ApiRetrieveRunPresenter.bufferedStatusToTaskRunStatus; both
|
||||
// CANCELED and SYSTEM_FAILURE are final run statuses, and SYSTEM_FAILURE
|
||||
// is also a failed status.
|
||||
const status: SpanRun["status"] = isCancelled
|
||||
? "CANCELED"
|
||||
: isFailed
|
||||
? "SYSTEM_FAILURE"
|
||||
: "PENDING";
|
||||
|
||||
// Mirror ApiRetrieveRunPresenter's STRING_ERROR synthesis so the panel
|
||||
// shows why a buffered run failed instead of an empty error block.
|
||||
const error: SpanRun["error"] =
|
||||
isFailed && run.error
|
||||
? { type: "STRING_ERROR", raw: `${run.error.code}: ${run.error.message}` }
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
friendlyId: run.friendlyId,
|
||||
status,
|
||||
statusReason: isCancelled
|
||||
? (run.cancelReason ?? undefined)
|
||||
: isFailed
|
||||
? (run.error?.message ?? undefined)
|
||||
: undefined,
|
||||
createdAt: run.createdAt,
|
||||
startedAt: null,
|
||||
executedAt: null,
|
||||
updatedAt: run.cancelledAt ?? run.createdAt,
|
||||
delayUntil: run.delayUntil ?? null,
|
||||
expiredAt: null,
|
||||
// Symmetric with `ApiRetrieveRunPresenter` — FAILED buffered runs
|
||||
// must surface a non-null `completedAt` so the run-detail panel
|
||||
// (and any caller checking `isFinished && completedAt`) doesn't
|
||||
// render a finished run with no completion timestamp. PG-resident
|
||||
// SYSTEM_FAILURE rows always have completedAt set; the buffer
|
||||
// entry has no separate failedAt, so we fall back to createdAt
|
||||
// as the best proxy for when the terminal state landed.
|
||||
completedAt: run.cancelledAt ?? (isFailed ? run.createdAt : null),
|
||||
logsDeletedAt: null,
|
||||
ttl: run.ttl ?? null,
|
||||
taskIdentifier: run.taskIdentifier ?? "",
|
||||
version: undefined,
|
||||
sdkVersion: undefined,
|
||||
runtime: undefined,
|
||||
runtimeVersion: undefined,
|
||||
isTest: run.isTest,
|
||||
replayedFromTaskRunFriendlyId: run.replayedFromTaskRunFriendlyId ?? null,
|
||||
environmentId: environment.id,
|
||||
idempotencyKey,
|
||||
idempotencyKeyExpiresAt: null,
|
||||
idempotencyKeyScope,
|
||||
idempotencyKeyStatus,
|
||||
debounce: null,
|
||||
schedule: undefined,
|
||||
queue: {
|
||||
name: queueName,
|
||||
isCustomQueue: !queueName.startsWith("task/"),
|
||||
concurrencyKey: run.concurrencyKey ?? null,
|
||||
},
|
||||
tags: run.runTags,
|
||||
baseCostInCents: 0,
|
||||
costInCents: 0,
|
||||
totalCostInCents: 0,
|
||||
usageDurationMs: 0,
|
||||
isFinished: isCancelled || isFailed,
|
||||
isRunning: false,
|
||||
isError: isFailed,
|
||||
isAgentRun,
|
||||
isScheduled,
|
||||
payload,
|
||||
payloadType: run.payloadType ?? "application/json",
|
||||
output: undefined,
|
||||
outputType: "application/json",
|
||||
error,
|
||||
// The snapshot only carries the root/parent friendly IDs, not the
|
||||
// spanId or taskIdentifier that SpanPresenter sources from the joined
|
||||
// PG rows. Emitting them with empty-string stubs renders a blank task
|
||||
// name and a misleading `?span=` jump target, so we omit the
|
||||
// relationships until the drainer materialises the row (a transient
|
||||
// window). Top-level buffered runs have no relationships regardless.
|
||||
relationships: {
|
||||
root: undefined,
|
||||
parent: undefined,
|
||||
},
|
||||
context: JSON.stringify(
|
||||
{
|
||||
task: {
|
||||
id: run.taskIdentifier ?? "",
|
||||
},
|
||||
run: {
|
||||
id: run.friendlyId,
|
||||
createdAt: run.createdAt,
|
||||
isTest: run.isTest,
|
||||
},
|
||||
environment: {
|
||||
id: environment.id,
|
||||
slug: environment.slug,
|
||||
type: environment.type,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
metadata,
|
||||
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
|
||||
batch: undefined,
|
||||
session: undefined,
|
||||
engine: "V2",
|
||||
region: null,
|
||||
workerQueue: run.workerQueue ?? "",
|
||||
traceId: run.traceId ?? "",
|
||||
spanId: run.spanId ?? "",
|
||||
isCached: false,
|
||||
isBuffered: true,
|
||||
machinePreset: narrowMachinePreset(run.machinePreset),
|
||||
taskEventStore: "taskEvent",
|
||||
externalTraceId: undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { millisecondsToNanoseconds } from "@trigger.dev/core/v3";
|
||||
import { createTreeFromFlatItems, flattenTree } from "~/components/primitives/TreeView/TreeView";
|
||||
import { createTimelineSpanEventsFromSpanEvents } from "~/utils/timelineSpanEvents";
|
||||
import type { SpanSummary } from "~/v3/eventRepository/eventRepository.types";
|
||||
import type { SyntheticRun } from "./readFallback.server";
|
||||
|
||||
// Build a single-span trace for a buffered run so the run-detail page
|
||||
// renders a meaningful timeline before the drainer materialises the
|
||||
// row. Mirrors the shape produced by `RunPresenter` when its trace
|
||||
// store lookup returns no spans, so the dashboard consumer treats the
|
||||
// buffered run identically to a freshly enqueued PG run that hasn't
|
||||
// emitted any events yet.
|
||||
export function buildSyntheticTraceForBufferedRun(run: SyntheticRun) {
|
||||
const spanId = run.spanId ?? "";
|
||||
const isCancelled = run.status === "CANCELED";
|
||||
const isFailed = run.status === "FAILED";
|
||||
const span: SpanSummary = {
|
||||
id: spanId,
|
||||
parentId: run.parentSpanId,
|
||||
runId: run.friendlyId,
|
||||
data: {
|
||||
message: run.taskIdentifier ?? "Task",
|
||||
style: { icon: "task", variant: "primary" },
|
||||
events: [],
|
||||
startTime: run.createdAt,
|
||||
duration: 0,
|
||||
isError: isFailed,
|
||||
// CANCELED and FAILED are terminal; only a still-queued buffered run
|
||||
// is partial. A partial failed span would otherwise render as
|
||||
// "executing" forever in the timeline.
|
||||
isPartial: !isCancelled && !isFailed,
|
||||
isCancelled,
|
||||
isDebug: false,
|
||||
level: "TRACE",
|
||||
},
|
||||
};
|
||||
|
||||
const tree = createTreeFromFlatItems([span], spanId);
|
||||
const treeRootStartTimeMs = tree?.data.startTime.getTime() ?? 0;
|
||||
const totalDuration = Math.max(tree?.data.duration ?? 0, millisecondsToNanoseconds(1));
|
||||
|
||||
const events = tree
|
||||
? flattenTree(tree).map((n) => {
|
||||
const offset = millisecondsToNanoseconds(n.data.startTime.getTime() - treeRootStartTimeMs);
|
||||
// Mirror RunPresenter: raw span events stay server-side, only
|
||||
// timelineEvents ship to the client.
|
||||
const { events: spanEvents, ...data } = n.data;
|
||||
return {
|
||||
...n,
|
||||
data: {
|
||||
...data,
|
||||
timelineEvents: createTimelineSpanEventsFromSpanEvents(
|
||||
spanEvents,
|
||||
false,
|
||||
treeRootStartTimeMs
|
||||
),
|
||||
duration: n.data.isPartial ? null : n.data.duration,
|
||||
offset,
|
||||
isRoot: n.id === spanId,
|
||||
// Synthetic traces represent buffered/queued/canceled runs that
|
||||
// haven't executed yet — they can't be agent runs. Keeping this
|
||||
// field present (instead of omitting it) keeps the trace shape
|
||||
// structurally identical to `RunPresenter`'s, which downstream
|
||||
// consumers like the agent-icon check in runs/$runParam/route
|
||||
// require to typecheck against the JsonifyObject union.
|
||||
isAgentRun: false,
|
||||
},
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return {
|
||||
// Matches RunPresenter's derivation: failed root span -> "failed",
|
||||
// otherwise a terminal (non-partial) span -> "completed", else
|
||||
// "executing". CANCELED is terminal-but-not-error, so "completed".
|
||||
rootSpanStatus: (isFailed ? "failed" : isCancelled ? "completed" : "executing") as
|
||||
| "executing"
|
||||
| "completed"
|
||||
| "failed",
|
||||
events,
|
||||
duration: totalDuration,
|
||||
rootStartedAt: tree?.data.startTime,
|
||||
startedAt: null,
|
||||
queuedDuration: undefined,
|
||||
overridesBySpanId: undefined,
|
||||
linkedRunIdBySpanId: {} as Record<string, string>,
|
||||
isTruncated: false,
|
||||
missingAnchor: false,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user