Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions apps/webapp/app/components/runs/v3/CancelRunDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,18 @@ import { SpinnerWhite } from "~/components/primitives/Spinner";
type CancelRunDialogProps = {
runFriendlyId: string;
redirectPath: string;
// Fired on submit so the parent can close the Radix Dialog without
// wrapping the submit button in `DialogClose` — that wrapper races
// submit (close fires first, unmounts the form, and the cancel POST
// never lands). Optional so existing call sites still type-check.
onCancelSubmitted?: () => void;
};

export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialogProps) {
export function CancelRunDialog({
runFriendlyId,
redirectPath,
onCancelSubmitted,
}: CancelRunDialogProps) {
const navigation = useNavigation();

const formAction = `/resources/taskruns/${runFriendlyId}/cancel`;
Expand All @@ -27,7 +36,11 @@ export function CancelRunDialog({ runFriendlyId, redirectPath }: CancelRunDialog
</Paragraph>
<FormButtons
confirmButton={
<Form action={`/resources/taskruns/${runFriendlyId}/cancel`} method="post">
<Form
action={`/resources/taskruns/${runFriendlyId}/cancel`}
method="post"
onSubmit={() => onCancelSubmitted?.()}
>
<Button
type="submit"
name="redirectUrl"
Expand Down
45 changes: 39 additions & 6 deletions apps/webapp/app/presenters/v3/RunStreamPresenter.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { logger } from "~/services/logger.server";
import { singleton } from "~/utils/singleton";
import { ABORT_REASON_SEND_ERROR, createSSELoader, SendFunction } from "~/utils/sse";
import { throttle } from "~/utils/throttle";
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
import { deserialiseMollifierSnapshot } from "~/v3/mollifier/mollifierSnapshot.server";
import { tracePubSub } from "~/v3/services/tracePubSub.server";

const PING_INTERVAL = 5_000;
Expand Down Expand Up @@ -37,17 +39,48 @@ export class RunStreamPresenter {
},
});

if (!run) {
// Fall back to the mollifier buffer when the run isn't in PG yet.
// The buffered run has no execution events to stream, but we still
// attach a trace-pubsub subscription using the snapshot's traceId
// so that the moment the drainer materialises the row and execution
// begins, those events flow to this open SSE connection. Closing
// with 404 would force the dashboard to keep retrying.
let traceId: string | null = run?.traceId ?? null;
if (!traceId) {
const buffer = getMollifierBuffer();
if (buffer) {
try {
const entry = await buffer.getEntry(runFriendlyId);
if (entry) {
// Go through the webapp wrapper so this read-side module
// shares a single deserialisation path with readFallback —
// see the contract comment in syntheticRedirectInfo.server.ts.
const snapshot = deserialiseMollifierSnapshot(entry.payload);
if (typeof snapshot.traceId === "string") {
traceId = snapshot.traceId;
}
}
} catch (err) {
logger.warn("RunStreamPresenter buffer fallback failed", {
runFriendlyId,
err: err instanceof Error ? err.message : String(err),
});
}
}
}

if (!traceId) {
throw new Response("Not found", { status: 404 });
}
const resolvedRun = { traceId };

logger.info("RunStreamPresenter.start", {
runFriendlyId,
traceId: run.traceId,
traceId: resolvedRun.traceId,
});

// Subscribe to trace updates
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(resolvedRun.traceId);

// Only send max every 1 second
const throttledSend = throttle(
Expand Down Expand Up @@ -105,7 +138,7 @@ export class RunStreamPresenter {
cleanup: () => {
logger.info("RunStreamPresenter.cleanup", {
runFriendlyId,
traceId: run.traceId,
traceId: resolvedRun.traceId,
});

// Remove message listener
Expand All @@ -119,13 +152,13 @@ export class RunStreamPresenter {
.then(() => {
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
runFriendlyId,
traceId: run.traceId,
traceId: resolvedRun.traceId,
});
})
.catch((error) => {
logger.error("RunStreamPresenter.cleanup.unsubscribe failed", {
runFriendlyId,
traceId: run.traceId,
traceId: resolvedRun.traceId,
error: {
name: error.name,
message: error.message,
Expand Down
37 changes: 34 additions & 3 deletions apps/webapp/app/routes/@.runs.$runParam.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { z } from "zod";
import { prisma } from "~/db.server";
import { redirectWithErrorMessage } from "~/models/message.server";
import { requireUser } from "~/services/session.server";
import { impersonate, rootPath, v3RunPath } from "~/utils/pathBuilder";
import { impersonate, rootPath, v3RunPath, v3RunSpanPath } from "~/utils/pathBuilder";
import { findBufferedRunRedirectInfo } from "~/v3/mollifier/syntheticRedirectInfo.server";

const ParamsSchema = z.object({
runParam: z.string(),
Expand Down Expand Up @@ -32,6 +33,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
friendlyId: runParam,
},
select: {
spanId: true,
runtimeEnvironment: {
select: {
slug: true,
Expand All @@ -51,16 +53,45 @@ export async function loader({ params, request }: LoaderFunctionArgs) {
});

if (!run) {
// Admin impersonation route — bypass org membership so admins can
// open any buffered run by friendlyId, mirroring the existing PG
// behaviour above (no membership filter on the find).
const buffered = await findBufferedRunRedirectInfo({
runFriendlyId: runParam,
userId: user.id,
skipOrgMembershipCheck: true,
});
if (buffered) {
// Preselect the root span so the run-detail trace tree opens with
// the buffered run's span highlighted, matching the sibling
// redirect routes (runs.$runParam.ts, projects.v3.$projectRef…).
const path = buffered.spanId
? v3RunSpanPath(
{ slug: buffered.organizationSlug },
{ slug: buffered.projectSlug },
{ slug: buffered.environmentSlug },
{ friendlyId: runParam },
{ spanId: buffered.spanId }
)
: v3RunPath(
{ slug: buffered.organizationSlug },
{ slug: buffered.projectSlug },
{ slug: buffered.environmentSlug },
{ friendlyId: runParam }
);
return redirect(impersonate(path));
}
return redirectWithErrorMessage(rootPath(), request, "Run doesn't exist", {
ephemeral: false,
});
}

const path = v3RunPath(
const path = v3RunSpanPath(
{ slug: run.project.organization.slug },
{ slug: run.project.slug },
{ slug: run.runtimeEnvironment.slug },
{ friendlyId: runParam }
{ friendlyId: runParam },
{ spanId: run.spanId }
);

return redirect(impersonate(path));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,14 @@ import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
import { useSearchParams } from "~/hooks/useSearchParam";
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useHasAdminAccess } from "~/hooks/useUser";
import { env } from "~/env.server";
import { findProjectBySlug } from "~/models/project.server";
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { NextRunListPresenter } from "~/presenters/v3/NextRunListPresenter.server";
import { RunEnvironmentMismatchError, RunPresenter } from "~/presenters/v3/RunPresenter.server";
import { findRunByIdWithMollifierFallback } from "~/v3/mollifier/readFallback.server";
import { buildSyntheticRunHeader } from "~/v3/mollifier/syntheticRunHeader.server";
import { buildSyntheticTraceForBufferedRun } from "~/v3/mollifier/syntheticTrace.server";
import { clickhouseFactory } from "~/services/clickhouse/clickhouseFactoryInstance.server";
import { getImpersonationId } from "~/services/impersonation.server";
import { logger } from "~/services/logger.server";
Expand Down Expand Up @@ -277,6 +281,31 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
);
}

// PG miss → try the mollifier buffer. When the gate diverts a trigger
// the run sits in Redis until the drainer materialises it; without
// this fallback the run-detail page 404s for the brief buffered window
// even though the API has accepted the trigger and returned an id.
const buffered = await tryMollifiedRunFallback({
runFriendlyId: runParam,
organizationSlug,
projectSlug: projectParam,
envSlug: envParam,
userId,
});

if (buffered) {
const parent = await getResizableSnapshot(request, resizableSettings.parent.autosaveId);
const tree = await getResizableSnapshot(request, resizableSettings.tree.autosaveId);

return json({
run: buffered.run,
trace: buffered.trace,
maximumLiveReloadingSetting: env.MAXIMUM_LIVE_RELOADING_EVENTS,
resizable: { parent, tree },
runsList: null,
});
}

throw error;
}

Expand Down Expand Up @@ -305,6 +334,39 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
});
};

async function tryMollifiedRunFallback(args: {
runFriendlyId: string;
organizationSlug: string;
projectSlug: string;
envSlug: string;
userId: string;
}) {
const project = await findProjectBySlug(args.organizationSlug, args.projectSlug, args.userId);
if (!project) return null;
const environment = await findEnvironmentBySlug(project.id, args.envSlug, args.userId);
if (!environment) return null;

const buffered = await findRunByIdWithMollifierFallback({
runId: args.runFriendlyId,
environmentId: environment.id,
organizationId: project.organizationId,
});
if (!buffered) return null;

return {
run: buildSyntheticRunHeader({
run: buffered,
environment: {
id: environment.id,
organizationId: project.organizationId,
type: environment.type,
slug: environment.slug,
},
}),
trace: buildSyntheticTraceForBufferedRun(buffered),
};
}

type LoaderData = SerializeFrom<typeof loader>;

export default function Page() {
Expand Down Expand Up @@ -407,23 +469,17 @@ export default function Page() {
/>
</Dialog>
{run.isFinished ? null : (
<Dialog key={`cancel-${run.friendlyId}`}>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon} shortcut={{ key: "C" }}>
Cancel run…
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={run.friendlyId}
redirectPath={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
/>
</Dialog>
<ControlledCancelRunDialog
key={`cancel-${run.friendlyId}`}
runFriendlyId={run.friendlyId}
redirectPath={v3RunSpanPath(
organization,
project,
environment,
{ friendlyId: run.friendlyId },
{ spanId: run.spanId }
)}
/>
)}
</PageAccessories>
</NavBar>
Expand Down Expand Up @@ -587,6 +643,35 @@ function TraceView({
);
}

// Controlled wrapper around the cancel dialog. Owns the Radix open state
// so the dialog closes itself once the cancel action transitions through
// submission. We can't `<DialogClose asChild>`-wrap the submit button
// because Radix's onClick handler swallows the button's name=value pair
// that the form action depends on for `redirectUrl`.
function ControlledCancelRunDialog({
runFriendlyId,
redirectPath,
}: {
runFriendlyId: string;
redirectPath: string;
}) {
const [open, setOpen] = useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="danger/small" LeadingIcon={StopCircleIcon} shortcut={{ key: "C" }}>
Cancel run…
</Button>
</DialogTrigger>
<CancelRunDialog
runFriendlyId={runFriendlyId}
redirectPath={redirectPath}
onCancelSubmitted={() => setOpen(false)}
/>
</Dialog>
);
}

function NoLogsView({ run, resizable }: Pick<LoaderData, "run" | "resizable">) {
const plan = useCurrentPlan();
const organization = useOrganization();
Expand Down Expand Up @@ -616,6 +701,11 @@ function NoLogsView({ run, resizable }: Pick<LoaderData, "run" | "resizable">) {
>
<div className="grid h-full place-items-center">
{daysSinceCompleted === undefined ? (
// NoLogsView only renders when the loader returns no trace.
// Buffered runs always carry a synthetic trace (see
// buildSyntheticTraceForBufferedRun) so they never reach
// this branch — the message here is the pre-mollifier
// copy for runs with no completedAt and no logs.
<InfoPanel variant="info" icon={InformationCircleIcon} title="We delete old logs">
<Paragraph variant="small">
We tidy up older logs to keep things running smoothly.
Expand Down
14 changes: 14 additions & 0 deletions apps/webapp/app/routes/api.v1.runs.$runId.metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ import type { RunMetadataChangeOperation } from "@trigger.dev/core/v3/schemas";
import { UpdateMetadataRequestBody } from "@trigger.dev/core/v3";
import { z } from "zod";
import { $replica } from "~/db.server";
// Aliased to avoid shadowing the local `env: AuthenticatedEnvironment`
// parameter the route handler and `routeOperationsToRun` use.
import { env as appEnv } from "~/env.server";
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
import { authenticateApiRequest } from "~/services/apiAuth.server";
import { logger } from "~/services/logger.server";
Expand Down Expand Up @@ -120,6 +123,7 @@ async function routeOperationsToRun(
runId: targetRunId,
environmentId: env.id,
organizationId: env.organizationId,
maximumSize: appEnv.TASK_RUN_METADATA_MAXIMUM_SIZE,
body: { operations },
})
);
Expand Down Expand Up @@ -164,12 +168,22 @@ const { action } = createActionApiRoute(
runId,
environmentId: env.id,
organizationId: env.organizationId,
maximumSize: appEnv.TASK_RUN_METADATA_MAXIMUM_SIZE,
body: { metadata: body.metadata, operations: body.operations },
});

if (bufferOutcome.kind === "not_found") {
return json({ error: "Task Run not found" }, { status: 404 });
}
if (bufferOutcome.kind === "metadata_too_large") {
// Mirror PG's `MetadataTooLargeError` (413).
return json(
{
error: `Metadata exceeds maximum size of ${bufferOutcome.maximumSize} bytes`,
},
{ status: 413 }
);
}
if (bufferOutcome.kind === "busy") {
// Entry is materialising. Best path is to retry the PG call —
// the row may be visible now. We don't waste a roundtrip in
Expand Down
Loading