make pipeline responsive at scale: cap review query, debounce sse reload, indexable done count
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
All checks were successful
Build and Push Docker Image / build (push) Successful in 37s
The pipeline endpoint returned every pending plan (no LIMIT) while the audio list capped at 500 — that alone was the main lag. SSE compounded it: every job_update (which fires per-line of running ffmpeg output) re-ran the entire endpoint and re-rendered every card. - review query: LIMIT 500 + a separate COUNT for reviewTotal; column header shows 'X of Y' and a footer 'Showing first X of Y. Approve some to see the rest' when truncated - doneCount: split the OR-form into two indexable counts (is_noop + done&!noop), added together — uses idx_review_plans_is_noop and idx_review_plans_status instead of full scan - pipeline page: 1s debounce on SSE-triggered reload so a burst of job_update events collapses into one refetch
This commit is contained in:
@@ -229,6 +229,10 @@ app.get("/pipeline", (c) => {
|
||||
const db = getDb();
|
||||
const jellyfinUrl = getConfig("jellyfin_url") ?? "";
|
||||
|
||||
// Cap the review column to keep the page snappy at scale; pipelines
|
||||
// with thousands of pending items would otherwise ship 10k+ rows on
|
||||
// every refresh and re-render every card.
|
||||
const REVIEW_LIMIT = 500;
|
||||
const review = db
|
||||
.prepare(`
|
||||
SELECT rp.*, mi.name, mi.series_name, mi.series_jellyfin_id,
|
||||
@@ -242,8 +246,12 @@ app.get("/pipeline", (c) => {
|
||||
CASE rp.confidence WHEN 'high' THEN 0 ELSE 1 END,
|
||||
COALESCE(mi.series_name, mi.name),
|
||||
mi.season_number, mi.episode_number
|
||||
LIMIT ${REVIEW_LIMIT}
|
||||
`)
|
||||
.all();
|
||||
const reviewTotal = (
|
||||
db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'pending' AND is_noop = 0").get() as { n: number }
|
||||
).n;
|
||||
|
||||
const queued = db
|
||||
.prepare(`
|
||||
@@ -281,17 +289,15 @@ app.get("/pipeline", (c) => {
|
||||
`)
|
||||
.all();
|
||||
|
||||
// "Done" = files that are already in the desired end state. Two ways
|
||||
// to get there: (a) the analyzer says nothing to do (is_noop=1), or
|
||||
// (b) we ran a job that finished. Both count toward the same total.
|
||||
const doneCount = (
|
||||
db
|
||||
.prepare(`
|
||||
SELECT COUNT(*) as count FROM review_plans
|
||||
WHERE is_noop = 1 OR status = 'done'
|
||||
`)
|
||||
.get() as { count: number }
|
||||
).count;
|
||||
// "Done" = files already in the desired end state. Either the analyzer
|
||||
// says nothing to do (is_noop=1) or a job finished. Use two indexable
|
||||
// counts and add — the OR form (is_noop=1 OR status='done') can't use
|
||||
// our single-column indexes and gets slow on large libraries.
|
||||
const noopRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE is_noop = 1").get() as { n: number };
|
||||
const doneRow = db.prepare("SELECT COUNT(*) as n FROM review_plans WHERE status = 'done' AND is_noop = 0").get() as {
|
||||
n: number;
|
||||
};
|
||||
const doneCount = noopRow.n + doneRow.n;
|
||||
|
||||
// Batch transcode reasons for all review plans in one query (avoids N+1)
|
||||
const planIds = (review as { id: number }[]).map((r) => r.id);
|
||||
@@ -315,7 +321,7 @@ app.get("/pipeline", (c) => {
|
||||
item.transcode_reasons = reasonsByPlan.get(item.id) ?? [];
|
||||
}
|
||||
|
||||
return c.json({ review, queued, processing, done, doneCount, jellyfinUrl });
|
||||
return c.json({ review, reviewTotal, queued, processing, done, doneCount, jellyfinUrl });
|
||||
});
|
||||
|
||||
// ─── List ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { api } from "~/shared/lib/api";
|
||||
import { DoneColumn } from "./DoneColumn";
|
||||
import { ProcessingColumn } from "./ProcessingColumn";
|
||||
@@ -8,6 +8,7 @@ import { ScheduleControls } from "./ScheduleControls";
|
||||
|
||||
interface PipelineData {
|
||||
review: any[];
|
||||
reviewTotal: number;
|
||||
queued: any[];
|
||||
processing: any[];
|
||||
done: any[];
|
||||
@@ -55,17 +56,30 @@ export function PipelinePage() {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
// SSE for live updates
|
||||
// SSE for live updates. job_update fires on every status change and per-line
|
||||
// stdout flush of the running job — without coalescing, the pipeline endpoint
|
||||
// (a 500-row review query + counts) would re-run several times per second.
|
||||
const reloadTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
useEffect(() => {
|
||||
const scheduleReload = () => {
|
||||
if (reloadTimer.current) return;
|
||||
reloadTimer.current = setTimeout(() => {
|
||||
reloadTimer.current = null;
|
||||
load();
|
||||
}, 1000);
|
||||
};
|
||||
const es = new EventSource("/api/execute/events");
|
||||
es.addEventListener("job_update", () => load());
|
||||
es.addEventListener("job_update", scheduleReload);
|
||||
es.addEventListener("job_progress", (e) => {
|
||||
setProgress(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
es.addEventListener("queue_status", (e) => {
|
||||
setQueueStatus(JSON.parse((e as MessageEvent).data));
|
||||
});
|
||||
return () => es.close();
|
||||
return () => {
|
||||
es.close();
|
||||
if (reloadTimer.current) clearTimeout(reloadTimer.current);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
if (loading || !data) return <div className="p-6 text-gray-500">Loading pipeline...</div>;
|
||||
@@ -80,7 +94,7 @@ export function PipelinePage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-1 gap-4 p-4 overflow-x-auto overflow-y-hidden min-h-0">
|
||||
<ReviewColumn items={data.review} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
||||
<ReviewColumn items={data.review} total={data.reviewTotal} jellyfinUrl={data.jellyfinUrl} onMutate={load} />
|
||||
<QueueColumn items={data.queued} />
|
||||
<ProcessingColumn items={data.processing} progress={progress} queueStatus={queueStatus} />
|
||||
<DoneColumn items={data.done} />
|
||||
|
||||
@@ -4,11 +4,13 @@ import { SeriesCard } from "./SeriesCard";
|
||||
|
||||
interface ReviewColumnProps {
|
||||
items: any[];
|
||||
total: number;
|
||||
jellyfinUrl: string;
|
||||
onMutate: () => void;
|
||||
}
|
||||
|
||||
export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
export function ReviewColumn({ items, total, jellyfinUrl, onMutate }: ReviewColumnProps) {
|
||||
const truncated = total > items.length;
|
||||
// Group by series (movies are standalone)
|
||||
const movies = items.filter((i: any) => i.type === "Movie");
|
||||
const seriesMap = new Map<string, { name: string; key: string; jellyfinId: string | null; episodes: any[] }>();
|
||||
@@ -45,7 +47,7 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
||||
return (
|
||||
<div className="flex flex-col w-96 min-w-96 min-h-0 bg-gray-50 rounded-lg">
|
||||
<div className="px-3 py-2 border-b font-medium text-sm">
|
||||
Review <span className="text-gray-400">({items.length})</span>
|
||||
Review <span className="text-gray-400">({truncated ? `${items.length} of ${total}` : total})</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-2">
|
||||
{allItems.map((entry) => {
|
||||
@@ -78,6 +80,11 @@ export function ReviewColumn({ items, jellyfinUrl, onMutate }: ReviewColumnProps
|
||||
}
|
||||
})}
|
||||
{allItems.length === 0 && <p className="text-sm text-gray-400 text-center py-8">No items to review</p>}
|
||||
{truncated && (
|
||||
<p className="text-xs text-gray-400 text-center py-3 border-t mt-2">
|
||||
Showing first {items.length} of {total}. Approve some to see the rest.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user