212 lines
7.7 KiB
TypeScript
212 lines
7.7 KiB
TypeScript
import { type PrismaClient, prisma } from "~/db.server";
|
|
import { logger } from "~/services/logger.server";
|
|
import { requireUserId } from "~/services/session.server";
|
|
import { singleton } from "~/utils/singleton";
|
|
import type { SendFunction } from "~/utils/sse";
|
|
import { ABORT_REASON_SEND_ERROR, createSSELoader } from "~/utils/sse";
|
|
import { throttle } from "~/utils/throttle";
|
|
import { getMollifierBuffer } from "~/v3/mollifier/mollifierBuffer.server";
|
|
import { deserialiseMollifierSnapshot } from "~/v3/mollifier/mollifierSnapshot.server";
|
|
import { runStore } from "~/v3/runStore.server";
|
|
import { tracePubSub } from "~/v3/services/tracePubSub.server";
|
|
|
|
const PING_INTERVAL = 5_000;
|
|
const STREAM_TIMEOUT = 30_000;
|
|
|
|
export class RunStreamPresenter {
|
|
#prismaClient: PrismaClient;
|
|
|
|
constructor(prismaClient: PrismaClient = prisma) {
|
|
this.#prismaClient = prismaClient;
|
|
}
|
|
|
|
public createLoader() {
|
|
const prismaClient = this.#prismaClient;
|
|
|
|
return createSSELoader({
|
|
timeout: STREAM_TIMEOUT,
|
|
interval: PING_INTERVAL,
|
|
handler: async (context) => {
|
|
const runFriendlyId = context.params.runParam;
|
|
|
|
if (!runFriendlyId) {
|
|
throw new Response("Missing runParam", { status: 400 });
|
|
}
|
|
|
|
const userId = await requireUserId(context.request);
|
|
|
|
// Scope the lookup to organizations the requesting user is a member
|
|
// of, matching RunPresenter's run lookup. Unauthorized and missing
|
|
// runs are indistinguishable (both 404).
|
|
// Run-ops read by friendlyId only (routes to the owning DB); the project/org
|
|
// membership auth is a control-plane concern resolved separately below — joining
|
|
// it here is a cross-DB join that returns nothing once the run lives in run-ops.
|
|
const run = await runStore.findRun(
|
|
{
|
|
friendlyId: runFriendlyId,
|
|
},
|
|
{
|
|
select: {
|
|
traceId: true,
|
|
projectId: true,
|
|
},
|
|
}
|
|
);
|
|
|
|
// Authorize on the control-plane DB, keyed by the run's project. A non-member
|
|
// (or unresolvable project) is treated as no-access: traceId stays null.
|
|
let authorized = false;
|
|
if (run) {
|
|
const authorizedProject = await prismaClient.project.findFirst({
|
|
where: {
|
|
id: run.projectId,
|
|
organization: { members: { some: { userId } } },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
authorized = authorizedProject !== null;
|
|
}
|
|
|
|
// 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 && authorized ? run.traceId : null;
|
|
if (!traceId) {
|
|
const buffer = getMollifierBuffer();
|
|
if (buffer) {
|
|
try {
|
|
const entry = await buffer.getEntry(runFriendlyId);
|
|
// Same membership scoping as the PG lookup above — the buffer
|
|
// entry carries the owning org's id.
|
|
const isMember = entry
|
|
? (await prismaClient.orgMember.findFirst({
|
|
where: { organizationId: entry.orgId, userId },
|
|
select: { id: true },
|
|
})) !== null
|
|
: false;
|
|
if (entry && isMember) {
|
|
// 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: resolvedRun.traceId,
|
|
});
|
|
|
|
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(
|
|
resolvedRun.traceId
|
|
);
|
|
|
|
// Only send max every 1 second
|
|
const throttledSend = throttle(
|
|
(args: { send: SendFunction; event?: string; data: string }) => {
|
|
try {
|
|
args.send({ event: args.event, data: args.data });
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
if (error.name !== "TypeError") {
|
|
logger.debug("Error sending SSE in RunStreamPresenter", {
|
|
error: {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
// Abort the stream on send error. Uses a stackless string sentinel
|
|
// from sse.ts — a no-arg abort() would create a DOMException with a
|
|
// stack trace, which is unnecessary retention on the signal.reason.
|
|
context.controller.abort(ABORT_REASON_SEND_ERROR);
|
|
}
|
|
},
|
|
1000
|
|
);
|
|
|
|
let messageListener: ((event: string) => void) | undefined;
|
|
|
|
return {
|
|
initStream: ({ send }) => {
|
|
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
|
|
|
messageListener = (event: string) => {
|
|
throttledSend({ send, event: "message", data: event });
|
|
};
|
|
eventEmitter.addListener("message", messageListener);
|
|
|
|
context.debug("Subscribed to trace pub/sub");
|
|
},
|
|
|
|
iterator: ({ send }) => {
|
|
try {
|
|
// Send an actual message so the client refreshes
|
|
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
|
} catch (_error) {
|
|
// If we can't send a ping, the connection is likely dead
|
|
return false;
|
|
}
|
|
},
|
|
|
|
cleanup: () => {
|
|
logger.info("RunStreamPresenter.cleanup", {
|
|
runFriendlyId,
|
|
traceId: resolvedRun.traceId,
|
|
});
|
|
|
|
if (messageListener) {
|
|
eventEmitter.removeListener("message", messageListener);
|
|
}
|
|
eventEmitter.removeAllListeners();
|
|
|
|
unsubscribe()
|
|
.then(() => {
|
|
logger.info("RunStreamPresenter.cleanup.unsubscribe succeeded", {
|
|
runFriendlyId,
|
|
traceId: resolvedRun.traceId,
|
|
});
|
|
})
|
|
.catch((error) => {
|
|
logger.error("RunStreamPresenter.cleanup.unsubscribe failed", {
|
|
runFriendlyId,
|
|
traceId: resolvedRun.traceId,
|
|
error: {
|
|
name: error.name,
|
|
message: error.message,
|
|
stack: error.stack,
|
|
},
|
|
});
|
|
});
|
|
},
|
|
};
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
export const runStreamLoader = singleton("runStreamLoader", () => {
|
|
const presenter = new RunStreamPresenter();
|
|
return presenter.createLoader();
|
|
});
|