From 75550f535ca47252f7f39b8a56861ae07523135b Mon Sep 17 00:00:00 2001
From: Eric Allam
Date: Mon, 13 Nov 2023 16:18:35 +0000
Subject: [PATCH] Fix undefined task outputs
And also fix the SSE connection getting reset on a infinite loop when runs are complete
---
apps/webapp/app/components/run/TaskDetail.tsx | 36 ++++---
apps/webapp/app/hooks/useEventSource.tsx | 9 +-
apps/webapp/app/models/task.server.ts | 6 +-
.../presenters/TaskDetailsPresenter.server.ts | 14 +--
.../route.tsx | 31 +++---
.../route.tsx | 1 +
.../route.tsx | 29 +++---
.../api.v1.runs.$runId.tasks.$id.complete.ts | 3 +-
.../runs/performRunExecutionV3.server.ts | 1 +
apps/webapp/app/utils/sse.server.ts | 30 +++---
.../migration.sql | 2 +
packages/database/prisma/schema.prisma | 23 ++---
references/job-catalog/package.json | 1 +
references/job-catalog/src/edge-cases.ts | 99 +++++++++++++++++++
14 files changed, 210 insertions(+), 75 deletions(-)
create mode 100644 packages/database/prisma/migrations/20231113151412_add_output_is_undefined/migration.sql
create mode 100644 references/job-catalog/src/edge-cases.ts
diff --git a/apps/webapp/app/components/run/TaskDetail.tsx b/apps/webapp/app/components/run/TaskDetail.tsx
index cd19f077b..bb4c9ec86 100644
--- a/apps/webapp/app/components/run/TaskDetail.tsx
+++ b/apps/webapp/app/components/run/TaskDetail.tsx
@@ -33,7 +33,19 @@ import { Spinner } from "../primitives/Spinner";
import type { DetailedTask } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route";
export function TaskDetail({ task }: { task: DetailedTask }) {
- const { name, description, icon, status, params, properties, output, style, attempts } = task;
+ const {
+ name,
+ description,
+ icon,
+ status,
+ params,
+ properties,
+ output,
+ outputIsUndefined,
+ style,
+ attempts,
+ noop,
+ } = task;
const startedAt = task.startedAt ? new Date(task.startedAt) : undefined;
const completedAt = task.completedAt ? new Date(task.completedAt) : undefined;
@@ -140,16 +152,18 @@ export function TaskDetail({ task }: { task: DetailedTask }) {
No input
)}
-
-
Output
- {output ? (
-
}>
- {() =>
}
-
- ) : (
-
No output
- )}
-
+ {!noop && (
+
+
Output
+ {output && !outputIsUndefined ? (
+
}>
+ {() =>
}
+
+ ) : (
+
No output
+ )}
+
+ )}
);
diff --git a/apps/webapp/app/hooks/useEventSource.tsx b/apps/webapp/app/hooks/useEventSource.tsx
index 98ca1c962..8a4c9a430 100644
--- a/apps/webapp/app/hooks/useEventSource.tsx
+++ b/apps/webapp/app/hooks/useEventSource.tsx
@@ -3,6 +3,7 @@ import { useEffect, useState } from "react";
type EventSourceOptions = {
init?: EventSourceInit;
event?: string;
+ disabled?: boolean;
};
/**
@@ -13,11 +14,15 @@ type EventSourceOptions = {
*/
export function useEventSource(
url: string | URL,
- { event = "message", init }: EventSourceOptions = {}
+ { event = "message", init, disabled }: EventSourceOptions = {}
) {
const [data, setData] = useState(null);
useEffect(() => {
+ if (disabled) {
+ return;
+ }
+
const eventSource = new EventSource(url, init);
eventSource.addEventListener(event ?? "message", handler);
@@ -32,7 +37,7 @@ export function useEventSource(
eventSource.removeEventListener(event ?? "message", handler);
eventSource.close();
};
- }, [url, event, init]);
+ }, [url, event, init, disabled]);
return data;
}
diff --git a/apps/webapp/app/models/task.server.ts b/apps/webapp/app/models/task.server.ts
index 83e2e46b1..c840e6f86 100644
--- a/apps/webapp/app/models/task.server.ts
+++ b/apps/webapp/app/models/task.server.ts
@@ -15,7 +15,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
status: task.status,
description: task.description,
params: task.params as any,
- output: task.output as any,
+ output: task.outputIsUndefined ? undefined : (task.output as any),
context: task.context as any,
properties: task.properties as any,
style: task.style as any,
@@ -32,7 +32,7 @@ export function taskWithAttemptsToServerTask(task: TaskWithAttempts): ServerTask
export type TaskForCaching = Pick<
Task,
- "id" | "status" | "idempotencyKey" | "noop" | "output" | "parentId"
+ "id" | "status" | "idempotencyKey" | "noop" | "output" | "parentId" | "outputIsUndefined"
>;
export function prepareTasksForCaching(
@@ -105,7 +105,7 @@ function prepareTaskForCaching(task: TaskForCaching): CachedTask {
status: task.status,
idempotencyKey: task.idempotencyKey,
noop: task.noop,
- output: task.output as any,
+ output: task.outputIsUndefined ? undefined : (task.output as any),
parentId: task.parentId,
};
}
diff --git a/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts b/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts
index 4d27df4b7..afcf56da9 100644
--- a/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts
+++ b/apps/webapp/app/presenters/TaskDetailsPresenter.server.ts
@@ -1,5 +1,4 @@
-import { RedactSchema } from "@trigger.dev/core";
-import { StyleSchema } from "@trigger.dev/core";
+import { RedactSchema, StyleSchema } from "@trigger.dev/core";
import { PrismaClient, prisma } from "~/db.server";
import { mergeProperties } from "~/utils/mergeProperties.server";
import { Redactor } from "~/utils/redactor";
@@ -58,6 +57,7 @@ export class TaskDetailsPresenter {
outputProperties: true,
params: true,
output: true,
+ outputIsUndefined: true,
error: true,
startedAt: true,
completedAt: true,
@@ -89,9 +89,11 @@ export class TaskDetailsPresenter {
return {
...task,
redact: undefined,
- output: task.output
- ? JSON.stringify(this.#stringifyOutputWithRedactions(task.output, task.redact), null, 2)
- : undefined,
+ output: JSON.stringify(
+ this.#stringifyOutputWithRedactions(task.output, task.redact),
+ null,
+ 2
+ ),
connection: task.runConnection,
params: task.params as Record,
properties: mergeProperties(task.properties, task.outputProperties),
@@ -101,7 +103,7 @@ export class TaskDetailsPresenter {
#stringifyOutputWithRedactions(output: any, redact: unknown): any {
if (!output) {
- return;
+ return output;
}
const parsedRedact = RedactSchema.safeParse(redact);
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx
index 52538a357..0f114b1b8 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam.tasks.$taskParam/route.tsx
@@ -1,6 +1,5 @@
-import { Await, useLoaderData } from "@remix-run/react";
-import { LoaderFunctionArgs, SerializeFrom, defer } from "@remix-run/server-runtime";
-import { Suspense } from "react";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
@@ -12,26 +11,28 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { taskParam } = TaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
- const taskPromise = presenter.call({
+ const task = await presenter.call({
userId,
id: taskParam,
});
- return defer({
- taskPromise,
+ return typedjson({
+ task,
});
};
-export type DetailedTask = NonNullable["taskPromise"]>>;
+export type DetailedTask = NonNullable["task"]>;
export default function Page() {
- const { taskPromise } = useLoaderData();
+ const { task } = useTypedLoaderData();
- return (
- }>
- Error loading task!
}>
- {(resolvedTask) => resolvedTask && }
-
-
- );
+ if (!task) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
}
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route.tsx
index fdcceb03b..a8a382e3b 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.jobs.$jobParam.runs.$runParam/route.tsx
@@ -67,6 +67,7 @@ export default function Page() {
const revalidator = useRevalidator();
const events = useEventSource(runStreamingPath(organization, project, job, run), {
event: "message",
+ disabled: !!run.completedAt,
});
useEffect(() => {
if (events !== null) {
diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.external.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.external.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx
index 1e07e6deb..532bafddb 100644
--- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.external.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx
+++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.triggers_.external.$triggerParam_.runs.$runParam.tasks.$taskParam/route.tsx
@@ -1,6 +1,5 @@
-import { Await, useLoaderData } from "@remix-run/react";
-import { LoaderFunctionArgs, defer } from "@remix-run/server-runtime";
-import { Suspense } from "react";
+import { LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { typedjson, useTypedLoaderData } from "remix-typedjson";
import { Spinner } from "~/components/primitives/Spinner";
import { TaskDetail } from "~/components/run/TaskDetail";
import { TaskDetailsPresenter } from "~/presenters/TaskDetailsPresenter.server";
@@ -12,24 +11,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
const { taskParam } = TriggerSourceRunTaskParamsSchema.parse(params);
const presenter = new TaskDetailsPresenter();
- const taskPromise = presenter.call({
+ const task = await presenter.call({
userId,
id: taskParam,
});
- return defer({
- taskPromise,
+ return typedjson({
+ task,
});
};
export default function Page() {
- const { taskPromise } = useLoaderData();
+ const { task } = useTypedLoaderData();
- return (
- }>
- Error loading task!}>
- {(resolvedTask) => resolvedTask && }
-
-
- );
+ if (!task) {
+ return (
+
+
+
+ );
+ }
+
+ return ;
}
diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.tasks.$id.complete.ts b/apps/webapp/app/routes/api.v1.runs.$runId.tasks.$id.complete.ts
index 74df41dc1..44a5be5e0 100644
--- a/apps/webapp/app/routes/api.v1.runs.$runId.tasks.$id.complete.ts
+++ b/apps/webapp/app/routes/api.v1.runs.$runId.tasks.$id.complete.ts
@@ -191,7 +191,8 @@ export class CompleteRunTaskService {
},
data: {
status: "COMPLETED",
- output: taskBody.output ?? undefined,
+ output: taskBody.output as any,
+ outputIsUndefined: typeof taskBody.output === "undefined",
completedAt: new Date(),
outputProperties: taskBody.properties,
},
diff --git a/apps/webapp/app/services/runs/performRunExecutionV3.server.ts b/apps/webapp/app/services/runs/performRunExecutionV3.server.ts
index 6beadd365..2472978b9 100644
--- a/apps/webapp/app/services/runs/performRunExecutionV3.server.ts
+++ b/apps/webapp/app/services/runs/performRunExecutionV3.server.ts
@@ -1220,6 +1220,7 @@ async function findRun(prisma: PrismaClientOrTransaction, id: string) {
status: true,
noop: true,
output: true,
+ outputIsUndefined: true,
parentId: true,
},
orderBy: {
diff --git a/apps/webapp/app/utils/sse.server.ts b/apps/webapp/app/utils/sse.server.ts
index 72ea6d24c..fced1fbaf 100644
--- a/apps/webapp/app/utils/sse.server.ts
+++ b/apps/webapp/app/utils/sse.server.ts
@@ -22,6 +22,16 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }:
return new Response("SSE disabled", { status: 200 });
}
+ let pinger: NodeJS.Timer | undefined = undefined;
+ let updater: NodeJS.Timer | undefined = undefined;
+ let timeout: NodeJS.Timeout | undefined = undefined;
+
+ const abort = () => {
+ clearInterval(pinger);
+ clearInterval(updater);
+ clearTimeout(timeout);
+ };
+
return eventStream(request.signal, (send, close) => {
const safeSend = (args: { event?: string; data: string }) => {
try {
@@ -49,30 +59,26 @@ export function sse({ request, pingInterval = 1000, updateInterval = 348, run }:
}
};
- const pinger = setInterval(() => {
+ pinger = setInterval(() => {
if (request.signal.aborted) {
- return close();
+ return abort();
}
safeSend({ event: "ping", data: new Date().toISOString() });
}, pingInterval);
- const updater = setInterval(() => {
+ updater = setInterval(() => {
if (request.signal.aborted) {
- return close();
+ return abort();
}
- run(safeSend, close);
+ run(safeSend, abort);
}, updateInterval);
- const timeout = setTimeout(() => {
- close();
+ timeout = setTimeout(() => {
+ close(); // close the connection after 1 minute of inactivity, which will refresh the connection (that's why we aren't using abort)
}, 60 * 1000); // 1 minute
- return () => {
- clearInterval(updater);
- clearInterval(pinger);
- clearTimeout(timeout);
- };
+ return abort;
});
}
diff --git a/packages/database/prisma/migrations/20231113151412_add_output_is_undefined/migration.sql b/packages/database/prisma/migrations/20231113151412_add_output_is_undefined/migration.sql
new file mode 100644
index 000000000..f70613b2e
--- /dev/null
+++ b/packages/database/prisma/migrations/20231113151412_add_output_is_undefined/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "Task" ADD COLUMN "outputIsUndefined" BOOLEAN NOT NULL DEFAULT false;
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 3e7fe37ee..2e357db70 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -882,17 +882,18 @@ model Task {
delayUntil DateTime?
noop Boolean @default(false)
- description String?
- properties Json?
- outputProperties Json?
- params Json?
- output Json?
- context Json?
- error String?
- redact Json?
- style Json?
- operation String?
- callbackUrl String?
+ description String?
+ properties Json?
+ outputProperties Json?
+ params Json?
+ output Json?
+ outputIsUndefined Boolean @default(false)
+ context Json?
+ error String?
+ redact Json?
+ style Json?
+ operation String?
+ callbackUrl String?
startedAt DateTime?
completedAt DateTime?
diff --git a/references/job-catalog/package.json b/references/job-catalog/package.json
index f91285120..9ddc68237 100644
--- a/references/job-catalog/package.json
+++ b/references/job-catalog/package.json
@@ -31,6 +31,7 @@
"cli-example": "nodemon --watch src/cli-example.ts -r tsconfig-paths/register -r dotenv/config src/cli-example.ts",
"invoke": "nodemon --watch src/invoke.ts -r tsconfig-paths/register -r dotenv/config src/invoke.ts",
"built-ins": "nodemon --watch src/built-ins.ts -r tsconfig-paths/register -r dotenv/config src/built-ins.ts",
+ "edge-cases": "nodemon --watch src/edge-cases.ts -r tsconfig-paths/register -r dotenv/config src/edge-cases.ts",
"dev:trigger": "trigger-cli dev --port 8080"
},
"dependencies": {
diff --git a/references/job-catalog/src/edge-cases.ts b/references/job-catalog/src/edge-cases.ts
new file mode 100644
index 000000000..bf70f7b27
--- /dev/null
+++ b/references/job-catalog/src/edge-cases.ts
@@ -0,0 +1,99 @@
+import { createExpressServer } from "@trigger.dev/express";
+import { TriggerClient, invokeTrigger } from "@trigger.dev/sdk";
+import fs from "node:fs";
+import fsPromises from "node:fs/promises";
+
+export const client = new TriggerClient({
+ id: "job-catalog",
+ apiKey: process.env["TRIGGER_API_KEY"],
+ apiUrl: process.env["TRIGGER_API_URL"],
+ verbose: true,
+ ioLogLocalEnabled: true,
+});
+
+client.defineJob({
+ id: "task-output-edge-cases",
+ name: "Task Output Edge Cases",
+ version: "1.0.0",
+ trigger: invokeTrigger(),
+ run: async (payload, io, ctx) => {
+ const result1 = await io.runTask("undefined", async (task) => {
+ return undefined;
+ });
+
+ if (typeof result1 !== "undefined") {
+ throw new Error(`Expected undefined, got ${typeof result1}: ${JSON.stringify(result1)}`);
+ }
+
+ const result2 = await io.runTask("null", async (task) => {
+ return null;
+ });
+
+ if (result2 !== null) {
+ throw new Error(`Expected null, got ${typeof result2}: ${JSON.stringify(result2)}`);
+ }
+
+ const result3 = await io.runTask("false", async (task) => {
+ return false;
+ });
+
+ if (typeof result3 !== "boolean" && result3 !== false) {
+ throw new Error(`Expected false, got ${typeof result3}: ${JSON.stringify(result3)}`);
+ }
+
+ const result4 = await io.runTask("true", async (task) => {
+ return true;
+ });
+
+ if (typeof result4 !== "boolean" && result4 !== true) {
+ throw new Error(`Expected true, got ${typeof result4}: ${JSON.stringify(result4)}`);
+ }
+
+ const result5 = await io.runTask("date", async (task) => {
+ return new Date();
+ });
+
+ if (typeof result5 !== "string" || new Date(result5).toString() === "Invalid Date") {
+ throw new Error(`Expected string, got ${typeof result5}: ${JSON.stringify(result5)}`);
+ }
+
+ const result6 = await io.runTask("object", async (task) => {
+ return {
+ a: 1,
+ b: "2",
+ c: true,
+ d: new Date(),
+ e: null,
+ f: undefined,
+ };
+ });
+
+ if (typeof result6 !== "object" || result6 === null) {
+ throw new Error(`Expected object, got ${typeof result6}: ${JSON.stringify(result6)}`);
+ }
+
+ const result7 = await io.runTask("array", async (task) => {
+ return [1, "2", true, new Date(), null, undefined];
+ });
+
+ if (!Array.isArray(result7)) {
+ throw new Error(`Expected array, got ${typeof result7}: ${JSON.stringify(result7)}`);
+ }
+
+ const result8 = await io.runTask("file", async (task) => {
+ return fs.createReadStream(__filename) as any;
+ });
+
+ const result9 = await io.runTask("read-file", async (task) => {
+ return fsPromises.readFile(__filename, "utf-8");
+ });
+
+ if (typeof result9 !== "string") {
+ throw new Error(`Expected string, got ${typeof result9}: ${JSON.stringify(result9)}`);
+ }
+
+ await io.wait("wait-1", 1);
+ },
+});
+
+createExpressServer(client);