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

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:
2026-04-13 10:00:08 +02:00
parent 9ee0dd445f
commit ec28e43484
3 changed files with 46 additions and 19 deletions

View File

@@ -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 ─────────────────────────────────────────────────────────────────────

View File

@@ -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} />

View File

@@ -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>
);