ee29393862
Switching between deployments in the dashboard re-fetched the whole build log stream from record zero and re-rendered the list line by line every time. Logs are now cached per deployment for the lifetime of the tab: revisiting a deployment shows its logs immediately, and the stream is resumed from the next unread record rather than restarted. Finished deployments whose stream has been read through the `finalized` event are served entirely from the cache. ### Changes The stream/cache logic moved out of the route into a `useDeploymentLogs` hook. On each deployment switch it seeds state from the cache, resumes the S2 read session at `nextSeqNum`, and writes back on cleanup or natural session end. Completion is derived from the stream's own `finalized` event (plus a terminal deployment status), not from the session closing, so a session cut short by token expiry or a proxy cannot pin a truncated log in the cache. Memory is bounded by a small LRU (`deploymentLogsCache`): at most 20 deployments and 20,000 log lines in total, least recently viewed evicted first. The most recently viewed deployment is always kept, so a single very large log can temporarily exceed the line budget on its own. Records are batched into one state update per tick instead of one per line.
170 lines
5.0 KiB
TypeScript
170 lines
5.0 KiB
TypeScript
import { S2, S2Error } from "@s2-dev/streamstore";
|
|
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
|
|
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
|
|
import { useEffect, useState } from "react";
|
|
import {
|
|
deploymentLogsCache,
|
|
type DeploymentLogEntry,
|
|
} from "~/components/runs/v3/deploymentLogsCache";
|
|
|
|
type DeploymentEventStream = {
|
|
s2: {
|
|
basin: string;
|
|
stream: string;
|
|
accessToken: string;
|
|
};
|
|
};
|
|
|
|
const FINISHED_DEPLOYMENT_STATUSES = new Set<WorkerDeploymentStatus>([
|
|
"DEPLOYED",
|
|
"FAILED",
|
|
"CANCELED",
|
|
"TIMED_OUT",
|
|
]);
|
|
|
|
type UseDeploymentLogsOptions = {
|
|
eventStream: DeploymentEventStream | undefined;
|
|
status: WorkerDeploymentStatus;
|
|
};
|
|
|
|
export function useDeploymentLogs({ eventStream, status }: UseDeploymentLogsOptions) {
|
|
const [logs, setLogs] = useState<readonly DeploymentLogEntry[]>([]);
|
|
const [isStreaming, setIsStreaming] = useState(true);
|
|
const [streamError, setStreamError] = useState<string | null>(null);
|
|
|
|
const basin = eventStream?.s2.basin;
|
|
const stream = eventStream?.s2.stream;
|
|
const accessToken = eventStream?.s2.accessToken;
|
|
|
|
useEffect(() => {
|
|
if (!basin || !stream || !accessToken) return;
|
|
|
|
const isFinished = FINISHED_DEPLOYMENT_STATUSES.has(status);
|
|
const cacheKey = `${basin}/${stream}`;
|
|
const cached = deploymentLogsCache.get(cacheKey);
|
|
|
|
let entries = cached?.logs ?? [];
|
|
let nextSeqNum = cached?.nextSeqNum ?? 0;
|
|
let pending: DeploymentLogEntry[] = [];
|
|
let flushTimer: ReturnType<typeof setTimeout> | undefined;
|
|
let finalized = cached?.finalized ?? false;
|
|
|
|
// oxlint-disable-next-line react/set-state-in-effect -- Seed from the cache when the selected deployment changes.
|
|
setLogs(entries);
|
|
setStreamError(null);
|
|
|
|
if (cached?.complete) {
|
|
setIsStreaming(false);
|
|
return;
|
|
}
|
|
|
|
setIsStreaming(true);
|
|
|
|
const abortController = new AbortController();
|
|
|
|
const flush = () => {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = undefined;
|
|
if (abortController.signal.aborted || pending.length === 0) return;
|
|
entries = entries.concat(pending);
|
|
pending = [];
|
|
setLogs(entries);
|
|
};
|
|
|
|
const push = (entry: DeploymentLogEntry) => {
|
|
pending.push(entry);
|
|
flushTimer ??= setTimeout(flush, 0);
|
|
};
|
|
|
|
const store = () => {
|
|
clearTimeout(flushTimer);
|
|
flushTimer = undefined;
|
|
if (pending.length > 0) {
|
|
entries = entries.concat(pending);
|
|
pending = [];
|
|
}
|
|
if (entries.length === 0 && nextSeqNum === 0 && !finalized) return;
|
|
deploymentLogsCache.set(cacheKey, {
|
|
logs: entries,
|
|
nextSeqNum,
|
|
finalized,
|
|
complete: finalized && isFinished,
|
|
});
|
|
};
|
|
|
|
const streamLogs = async () => {
|
|
try {
|
|
const s2Stream = new S2({ accessToken }).basin(basin).stream(stream);
|
|
|
|
do {
|
|
const readSession = await s2Stream.readSession(
|
|
{
|
|
start: { from: { seqNum: nextSeqNum }, clamp: true },
|
|
stop: { waitSecs: 60 },
|
|
},
|
|
{ signal: abortController.signal }
|
|
);
|
|
|
|
for await (const record of readSession) {
|
|
nextSeqNum = record.seqNum + 1;
|
|
|
|
const decoded = record.body;
|
|
const result = DeploymentEventFromString.safeParse(decoded);
|
|
|
|
if (!result.success) {
|
|
// fallback to the previous format in s2 logs for compatibility
|
|
const headers: Record<string, string> = {};
|
|
if (record.headers) {
|
|
for (const [name, value] of record.headers) {
|
|
headers[name] = value;
|
|
}
|
|
}
|
|
const level =
|
|
(headers["level"]?.toLowerCase() as DeploymentLogEntry["level"]) ?? "info";
|
|
|
|
push({ timestamp: new Date(record.timestamp), message: decoded, level });
|
|
continue;
|
|
}
|
|
|
|
const event = result.data;
|
|
if (event.type === "finalized") finalized = true;
|
|
if (event.type !== "log") continue;
|
|
|
|
push({
|
|
timestamp: new Date(record.timestamp),
|
|
message: event.data.message,
|
|
level: event.data.level,
|
|
});
|
|
}
|
|
} while (!abortController.signal.aborted && !finalized && !isFinished);
|
|
} catch (error) {
|
|
if (abortController.signal.aborted) return;
|
|
|
|
if (error instanceof S2Error && error.code === "stream_not_found") {
|
|
finalized = isFinished;
|
|
return;
|
|
}
|
|
if (error instanceof S2Error && error.code === "permission_denied") return;
|
|
|
|
console.error("Failed to stream logs:", error);
|
|
setStreamError("Failed to stream logs");
|
|
} finally {
|
|
if (!abortController.signal.aborted) {
|
|
flush();
|
|
setIsStreaming(false);
|
|
store();
|
|
}
|
|
}
|
|
};
|
|
|
|
streamLogs();
|
|
|
|
return () => {
|
|
abortController.abort();
|
|
store();
|
|
};
|
|
}, [basin, stream, accessToken, status]);
|
|
|
|
return { logs, isStreaming, streamError };
|
|
}
|