146 lines
4.5 KiB
TypeScript
146 lines
4.5 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { Job } from "bullmq";
|
|
import { getPipelineQueue, getValhallaQueue } from "@/lib/queue";
|
|
import type { PipelineJobData, JobProgress } from "@/lib/queue";
|
|
import type { SSEEvent } from "@transportationer/shared";
|
|
|
|
export const runtime = "nodejs";
|
|
|
|
function fmt(event: SSEEvent): string {
|
|
return `data: ${JSON.stringify(event)}\n\n`;
|
|
}
|
|
|
|
export async function GET(
|
|
req: NextRequest,
|
|
{ params }: { params: Promise<{ id: string }> },
|
|
): Promise<Response> {
|
|
const { id } = await params;
|
|
const queue = getPipelineQueue();
|
|
const encoder = new TextEncoder();
|
|
let timer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
// Resolve citySlug from the refresh-city job that was returned to the UI.
|
|
// We track progress by citySlug across all pipeline stages because
|
|
// refresh-city itself completes almost immediately after enqueueing children.
|
|
let citySlug: string;
|
|
try {
|
|
const job = await Job.fromId<PipelineJobData>(queue, id);
|
|
if (!job) {
|
|
return new Response(fmt({ type: "failed", jobId: id, error: "Job not found" }), {
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
});
|
|
}
|
|
citySlug = job.data.citySlug ?? "";
|
|
} catch {
|
|
return new Response(fmt({ type: "failed", jobId: id, error: "Queue unavailable" }), {
|
|
headers: { "Content-Type": "text/event-stream" },
|
|
});
|
|
}
|
|
|
|
const valhallaQueue = getValhallaQueue();
|
|
|
|
const stream = new ReadableStream({
|
|
start(ctrl) {
|
|
const enqueue = (e: SSEEvent) => {
|
|
try {
|
|
ctrl.enqueue(encoder.encode(fmt(e)));
|
|
} catch {
|
|
/* controller already closed */
|
|
}
|
|
};
|
|
|
|
const cleanup = () => {
|
|
if (timer) { clearInterval(timer); timer = null; }
|
|
try { ctrl.close(); } catch { /* already closed */ }
|
|
};
|
|
|
|
const poll = async () => {
|
|
try {
|
|
// 1. Find the currently active stage across both queues.
|
|
const [pipelineActive, valhallaActive] = await Promise.all([
|
|
queue.getActive(0, 100),
|
|
valhallaQueue.getActive(0, 100),
|
|
]);
|
|
const activeJob = [...pipelineActive, ...valhallaActive].find(
|
|
(j) => j.data.citySlug === citySlug && j.data.type !== "refresh-city",
|
|
);
|
|
|
|
if (activeJob) {
|
|
const p = activeJob.progress as JobProgress | undefined;
|
|
if (p?.stage) {
|
|
enqueue({
|
|
type: "progress",
|
|
stage: p.stage,
|
|
pct: p.pct,
|
|
message: p.message,
|
|
bytesDownloaded: p.bytesDownloaded,
|
|
totalBytes: p.totalBytes,
|
|
});
|
|
} else {
|
|
enqueue({ type: "heartbeat" });
|
|
}
|
|
return;
|
|
}
|
|
|
|
// 2. No active stage — check for a recent failure in either queue.
|
|
const [pipelineFailed, valhallaFailed] = await Promise.all([
|
|
queue.getFailed(0, 50),
|
|
valhallaQueue.getFailed(0, 50),
|
|
]);
|
|
const recentFail = [...pipelineFailed, ...valhallaFailed].find(
|
|
(j) =>
|
|
j.data.citySlug === citySlug &&
|
|
j.data.type !== "refresh-city" &&
|
|
Date.now() - (j.finishedOn ?? 0) < 600_000,
|
|
);
|
|
if (recentFail) {
|
|
enqueue({
|
|
type: "failed",
|
|
jobId: recentFail.id ?? "",
|
|
error: recentFail.failedReason ?? "Pipeline stage failed",
|
|
});
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
// 3. Check if compute-scores completed recently → full pipeline done.
|
|
const completed = await queue.getCompleted(0, 100);
|
|
const finalDone = completed.find(
|
|
(j) =>
|
|
j.data.citySlug === citySlug &&
|
|
j.data.type === "compute-scores" &&
|
|
Date.now() - (j.finishedOn ?? 0) < 3_600_000,
|
|
);
|
|
if (finalDone) {
|
|
enqueue({ type: "completed", jobId: finalDone.id ?? "" });
|
|
cleanup();
|
|
return;
|
|
}
|
|
|
|
// 4. Still pending — heartbeat.
|
|
enqueue({ type: "heartbeat" });
|
|
} catch {
|
|
enqueue({ type: "heartbeat" });
|
|
}
|
|
};
|
|
|
|
poll();
|
|
timer = setInterval(poll, 1000);
|
|
|
|
req.signal.addEventListener("abort", cleanup);
|
|
},
|
|
|
|
cancel() {
|
|
if (timer) clearInterval(timer);
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-cache, no-transform",
|
|
Connection: "keep-alive",
|
|
"X-Accel-Buffering": "no",
|
|
},
|
|
});
|
|
}
|